Ticket > Details > Projekte: geheimer Vorschau-Link (erstellen, kopieren, oeffnen, neu, abschalten)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c44c42681
commit
1a6e533a9a
6 files changed
+132
No files matched your search
@@ -4,6 +4,7 @@ import PreviewBlock from './PreviewBlock'
|
||||
import AnalyseRow from './AnalyseRow'
|
||||
import GiteaLinkButton from './GiteaLinkButton'
|
||||
import PreviewLinkButton from './PreviewLinkButton'
|
||||
import VorschauLinkRow from './VorschauLinkRow'
|
||||
import { statusValueTone } from './projectTones'
|
||||
import { suggestSubdomain } from '../lib/subdomain'
|
||||
import { needsPreviewButton, assignSummary } from '../lib/projectAssign'
|
||||
@@ -87,6 +88,8 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
||||
|
||||
{project.subdomain && <AnalyseRow key={project.$id} projectId={project.$id} />}
|
||||
|
||||
{showActions && project.subdomain && <VorschauLinkRow key={`vl-${project.$id}`} project={project} onChanged={onChanged} />}
|
||||
|
||||
{showActions && project.subdomain && (
|
||||
<>
|
||||
<button type="button" className="link" aria-expanded={showPreviewBlock} onClick={() => setShowPreviewBlock((v) => !v)}>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react'
|
||||
import { copyTextChecked } from './CopyableCredential'
|
||||
import { erzeugeVorschauLink, schalteVorschauLinkAb } from '../lib/projectAdminApi'
|
||||
import { vorschauLinkUrl } from '../lib/vorschauLink'
|
||||
|
||||
// Geheimer Vorschau-Link im Ticket (Details > Projekte): wer den Link hat, sieht die Seite ohne Login.
|
||||
export default function VorschauLinkRow({ project, onChanged = () => {} }) {
|
||||
const [token, setToken] = useState(project.shareToken || '')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const url = vorschauLinkUrl({ subdomain: project.subdomain, shareToken: token })
|
||||
|
||||
const run = async (action) => {
|
||||
setBusy(true)
|
||||
setMessage('')
|
||||
try {
|
||||
await action()
|
||||
onChanged()
|
||||
} catch (err) {
|
||||
setMessage(err.message || 'Aktion fehlgeschlagen')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const erzeugen = (neu) => {
|
||||
if (neu && !window.confirm('Neuen Link erzeugen? Der bisherige Link funktioniert dann nicht mehr.')) return
|
||||
run(async () => {
|
||||
const r = await erzeugeVorschauLink(project.$id)
|
||||
setToken(r.shareToken || '')
|
||||
setMessage(neu ? 'Neuer Link erzeugt, der alte gilt nicht mehr.' : 'Link erstellt.')
|
||||
})
|
||||
}
|
||||
|
||||
const abschalten = () => {
|
||||
if (!window.confirm('Link abschalten? Wer ihn hat, kommt dann nicht mehr auf die Seite.')) return
|
||||
run(async () => {
|
||||
await schalteVorschauLinkAb(project.$id)
|
||||
setToken('')
|
||||
setMessage('Link abgeschaltet.')
|
||||
})
|
||||
}
|
||||
|
||||
const kopieren = async () => {
|
||||
try {
|
||||
await copyTextChecked(url)
|
||||
setMessage('Link kopiert.')
|
||||
} catch {
|
||||
setMessage('Kopieren ging nicht – bitte den Link oben markieren und kopieren.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vorschau-link" data-testid="vorschau-link">
|
||||
<div className="vorschau-link-head">
|
||||
<span className="vorschau-link-label">Vorschau-Link</span>
|
||||
<span className="muted xs">Ohne Login, nur für wen du ihn teilst</span>
|
||||
</div>
|
||||
{url ? (
|
||||
<>
|
||||
<input
|
||||
className="input vorschau-link-url"
|
||||
readOnly
|
||||
aria-label="Geheimer Vorschau-Link"
|
||||
value={url}
|
||||
onFocus={(e) => e.target.select()}
|
||||
/>
|
||||
<div className="row-actions">
|
||||
<button type="button" className="link" onClick={kopieren}>Kopieren</button>
|
||||
<a className="link" href={url} target="_blank" rel="noopener noreferrer">Öffnen</a>
|
||||
<button type="button" className="link" disabled={busy} onClick={() => erzeugen(true)}>Neuer Link</button>
|
||||
<button type="button" className="link link-danger" disabled={busy} onClick={abschalten}>Abschalten</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="row-actions">
|
||||
<button type="button" className="link" disabled={busy} onClick={() => erzeugen(false)}>
|
||||
{busy ? 'Wird erstellt …' : 'Link erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{message && <p className="msg muted" role="status">{message}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -57,6 +57,15 @@ export async function updateWebsiteProject(id, payload) {
|
||||
})
|
||||
}
|
||||
|
||||
// Geheimer Vorschau-Link: neu erzeugen (der alte wird ungültig) bzw. abschalten
|
||||
export async function erzeugeVorschauLink(id) {
|
||||
return adminFetch(`/api/admin/website-projects/${id}/vorschau-link`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function schalteVorschauLinkAb(id) {
|
||||
return adminFetch(`/api/admin/website-projects/${id}/vorschau-link`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export async function archiveWebsiteProject(id) {
|
||||
return adminFetch(`/api/admin/website-projects/${id}/archive`, { method: 'POST' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Geheimer Vorschau-Link (2026-10-26): wer ihn kennt, sieht die Preview ohne Login.
|
||||
// Das Portal löst ihn ein (Cookie + Weiterleitung), siehe Kundenportal server/services/vorschauLink.js.
|
||||
const PORTAL = 'https://project.webklar.com'
|
||||
|
||||
export function vorschauLinkUrl(project, base = PORTAL) {
|
||||
const subdomain = String(project?.subdomain || '').trim().toLowerCase()
|
||||
const token = String(project?.shareToken || '').trim()
|
||||
if (!subdomain || !token) return ''
|
||||
return `${String(base).replace(/\/$/, '')}/vorschau/${encodeURIComponent(subdomain)}/${encodeURIComponent(token)}`
|
||||
}
|
||||
@@ -1112,6 +1112,12 @@ button.link { text-align: left; } /* Reset und Gewicht von button.link stehen im
|
||||
.proj-head { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px 16px; }
|
||||
.proj-main { min-width: 0; overflow-wrap: anywhere; }
|
||||
.proj-meta { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; color: var(--text-2); }
|
||||
/* Geheimer Vorschau-Link: Feld volle Breite, Aktionen darunter */
|
||||
.vorschau-link { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; }
|
||||
.vorschau-link-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
|
||||
.vorschau-link-label { font-size: 13px; font-weight: 600; }
|
||||
.vorschau-link-url { width: 100%; min-width: 0; font-size: 13px; }
|
||||
.vorschau-link .msg { margin: 0; }
|
||||
.ticket-projects-assign { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
|
||||
.ticket-projects-assign .select { flex: 1 1 220px; min-width: 0; }
|
||||
.ticket-projects-assign .input { flex: 1 1 160px; min-width: 0; }
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { vorschauLinkUrl } = await loadModule('src/lib/vorschauLink.js')
|
||||
|
||||
test('vorschauLinkUrl: Link aus Subdomain und Token', () => {
|
||||
assert.equal(
|
||||
vorschauLinkUrl({ subdomain: 'Doener-Nation', shareToken: 'abcDEF_123-xyzxyzxyzxyz' }),
|
||||
'https://project.webklar.com/vorschau/doener-nation/abcDEF_123-xyzxyzxyzxyz'
|
||||
)
|
||||
assert.equal(vorschauLinkUrl({ subdomain: 'a', shareToken: 't' }, 'http://x/'), 'http://x/vorschau/a/t')
|
||||
})
|
||||
|
||||
test('vorschauLinkUrl: ohne Token oder Subdomain kein Link', () => {
|
||||
assert.equal(vorschauLinkUrl({ subdomain: 'a', shareToken: '' }), '')
|
||||
assert.equal(vorschauLinkUrl({ subdomain: '', shareToken: 'x' }), '')
|
||||
assert.equal(vorschauLinkUrl(null), '')
|
||||
})
|
||||
Reference in new issue
Block a user