feat(canary): landing — specimen requisition page + Turnstile widget
Landing route (/) is the token-creation page, framed as a "specimen requisition" intake form per the vector-minimalism ethos. Mind: cataloging a thing that resists cataloging — a trap baited for an unseen visitor. Page composition: - Top archive Strip (FIELD STATION / VOL / ISSUE pill) - Hero block: large blocky "CANARIES" headline, Latin subtitle, purpose line - Halftone separator - Five sections (01 species / 02 annotate / 03 route / 04 species config / 05 verify) - Bottom Strip with routing/license metadata Form sub-sections (each a small component to keep complexity under biome's maxAllowedComplexity=25 cap on FormView): - SpeciesSection — 7-card grid via TypePicker (radio fieldset, sr-only inputs) - AnnotateSection — memo textarea + conditional filename (when file-kind) - RouteSection — Telegram | Webhook radio fieldset + conditional credentials - ConfigSection — slowredirect destination_url or envfile include_keys - VerifySection — Turnstile widget if VITE_TURNSTILE_SITE_KEY present - SubmitFooter — release button + privacy note Validation: client uses createTokenRequestSchema.safeParse, surfaces field errors inline (mono caps in alarm color); server-side ApiError.fields are mapped back into the same FieldErrors shape via buildSubmissionState (Phase 14 single-source envelope contract preserved). Result view: SpecimenCard with full token dossier (DataRows), kind-discriminated artifact display (url → CopyField; file/text → download Blob via object-URL; connection_string → CopyField with note), Manage + Trigger CopyFields, link to the dossier route /m/:manage_id (route wired in next chunk). Turnstile: explicit-render widget, lazy script-load, registers token via setTurnstileTokenProvider on success; cleans up on unmount; no key = no widget (matches backend dev-bypass at internal/turnstile/verifier.go). Gates green: typecheck + biome + stylelint + build (406ms, landing chunk 123KB / 38KB gzip).
This commit is contained in:
parent
f5d893ab8c
commit
5562f82f34
|
|
@ -0,0 +1,136 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// index.tsx
|
||||
// ===================
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { setTurnstileTokenProvider } from '@/api'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render: (
|
||||
container: HTMLElement,
|
||||
options: {
|
||||
sitekey: string
|
||||
callback?: (token: string) => void
|
||||
'expired-callback'?: () => void
|
||||
'error-callback'?: () => void
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
appearance?: 'always' | 'execute' | 'interaction-only'
|
||||
size?: 'normal' | 'flexible' | 'compact' | 'invisible'
|
||||
}
|
||||
) => string
|
||||
reset: (widgetId?: string) => void
|
||||
remove: (widgetId?: string) => void
|
||||
}
|
||||
onloadTurnstileCallback?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const TURNSTILE_SRC =
|
||||
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onloadTurnstileCallback'
|
||||
const TURNSTILE_SCRIPT_ID = 'cf-turnstile-script'
|
||||
|
||||
function loadTurnstileScript(): Promise<void> {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (window.turnstile) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const existing = document.getElementById(TURNSTILE_SCRIPT_ID)
|
||||
if (existing) {
|
||||
existing.addEventListener('load', () => resolve(), { once: true })
|
||||
existing.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('turnstile script failed to load')),
|
||||
{ once: true }
|
||||
)
|
||||
return
|
||||
}
|
||||
window.onloadTurnstileCallback = () => resolve()
|
||||
const script = document.createElement('script')
|
||||
script.id = TURNSTILE_SCRIPT_ID
|
||||
script.src = TURNSTILE_SRC
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.addEventListener(
|
||||
'error',
|
||||
() => reject(new Error('turnstile script failed to load')),
|
||||
{ once: true }
|
||||
)
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
type TurnstileProps = {
|
||||
siteKey: string
|
||||
appearance?: 'always' | 'execute' | 'interaction-only'
|
||||
}
|
||||
|
||||
export function Turnstile({
|
||||
siteKey,
|
||||
appearance = 'always',
|
||||
}: TurnstileProps): React.ReactElement {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
const widgetIdRef = useRef<string | null>(null)
|
||||
const tokenRef = useRef<string | null>(null)
|
||||
const [status, setStatus] = useState<'idle' | 'ready' | 'error'>('idle')
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setTurnstileTokenProvider(() => tokenRef.current)
|
||||
loadTurnstileScript()
|
||||
.then(() => {
|
||||
if (cancelled || !window.turnstile || !containerRef.current) {
|
||||
return
|
||||
}
|
||||
widgetIdRef.current = window.turnstile.render(containerRef.current, {
|
||||
sitekey: siteKey,
|
||||
appearance,
|
||||
callback: (token: string) => {
|
||||
tokenRef.current = token
|
||||
setStatus('ready')
|
||||
},
|
||||
'expired-callback': () => {
|
||||
tokenRef.current = null
|
||||
setStatus('idle')
|
||||
},
|
||||
'error-callback': () => {
|
||||
tokenRef.current = null
|
||||
setStatus('error')
|
||||
},
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setStatus('error')
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
setTurnstileTokenProvider(null)
|
||||
if (widgetIdRef.current && window.turnstile) {
|
||||
window.turnstile.remove(widgetIdRef.current)
|
||||
widgetIdRef.current = null
|
||||
}
|
||||
tokenRef.current = null
|
||||
}
|
||||
}, [siteKey, appearance])
|
||||
|
||||
return (
|
||||
<div data-turnstile-status={status}>
|
||||
<div ref={containerRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function getTurnstileSiteKey(): string | null {
|
||||
const key = import.meta.env.VITE_TURNSTILE_SITE_KEY
|
||||
if (typeof key !== 'string' || key.length === 0) {
|
||||
return null
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// artifact.tsx
|
||||
// ===================
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import type { Artifact } from '@/api'
|
||||
import { CopyField, Pill } from '@/components'
|
||||
import styles from './landing.module.scss'
|
||||
|
||||
type ArtifactDisplayProps = {
|
||||
artifact: Artifact
|
||||
filenameFallback: string
|
||||
}
|
||||
|
||||
export function ArtifactDisplay({
|
||||
artifact,
|
||||
filenameFallback,
|
||||
}: ArtifactDisplayProps): React.ReactElement {
|
||||
if (artifact.kind === 'url') {
|
||||
return <UrlArtifact artifact={artifact} />
|
||||
}
|
||||
if (artifact.kind === 'file') {
|
||||
return (
|
||||
<FileArtifact artifact={artifact} filenameFallback={filenameFallback} />
|
||||
)
|
||||
}
|
||||
if (artifact.kind === 'text') {
|
||||
return (
|
||||
<TextArtifact artifact={artifact} filenameFallback={filenameFallback} />
|
||||
)
|
||||
}
|
||||
return <ConnectionStringArtifact artifact={artifact} />
|
||||
}
|
||||
|
||||
function UrlArtifact({
|
||||
artifact,
|
||||
}: {
|
||||
artifact: Extract<Artifact, { kind: 'url' }>
|
||||
}): React.ReactElement {
|
||||
const url = artifact.url ?? ''
|
||||
return (
|
||||
<div className={styles.artifactStack}>
|
||||
<CopyField label="TRIGGER URL" value={url} fullWidth />
|
||||
{artifact.destination_url ? (
|
||||
<CopyField
|
||||
label="DESTINATION"
|
||||
value={artifact.destination_url}
|
||||
fullWidth
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileArtifact({
|
||||
artifact,
|
||||
filenameFallback,
|
||||
}: {
|
||||
artifact: Extract<Artifact, { kind: 'file' }>
|
||||
filenameFallback: string
|
||||
}): React.ReactElement {
|
||||
const filename = artifact.filename ?? filenameFallback
|
||||
const contentType = artifact.content_type ?? 'application/octet-stream'
|
||||
const href = useMemo(() => {
|
||||
if (!artifact.content_b64) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return base64ToObjectUrl(artifact.content_b64, contentType)
|
||||
} catch (_err) {
|
||||
return null
|
||||
}
|
||||
}, [artifact.content_b64, contentType])
|
||||
|
||||
if (!href) {
|
||||
return <Pill tone="alarm">artifact unreadable</Pill>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.artifactStack}>
|
||||
<div className={styles.fileBar}>
|
||||
<span className={styles.fileMeta}>
|
||||
<span className={styles.fileLabel}>FILE</span>
|
||||
<span className={styles.fileName}>{filename}</span>
|
||||
<span className={styles.fileType}>{contentType}</span>
|
||||
</span>
|
||||
<a
|
||||
className={styles.downloadBtn}
|
||||
href={href}
|
||||
download={filename}
|
||||
onClick={() => toast.success(`Issuing ${filename}`)}
|
||||
>
|
||||
download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextArtifact({
|
||||
artifact,
|
||||
filenameFallback,
|
||||
}: {
|
||||
artifact: Extract<Artifact, { kind: 'text' }>
|
||||
filenameFallback: string
|
||||
}): React.ReactElement {
|
||||
const filename = artifact.filename ?? filenameFallback
|
||||
const content = artifact.content ?? ''
|
||||
const contentType = artifact.content_type ?? 'text/plain'
|
||||
const href = useMemo(() => {
|
||||
if (content.length === 0) {
|
||||
return null
|
||||
}
|
||||
const blob = new Blob([content], { type: contentType })
|
||||
return URL.createObjectURL(blob)
|
||||
}, [content, contentType])
|
||||
|
||||
return (
|
||||
<div className={styles.artifactStack}>
|
||||
<div className={styles.fileBar}>
|
||||
<span className={styles.fileMeta}>
|
||||
<span className={styles.fileLabel}>TEXT</span>
|
||||
<span className={styles.fileName}>{filename}</span>
|
||||
<span className={styles.fileType}>{contentType}</span>
|
||||
</span>
|
||||
{href ? (
|
||||
<a className={styles.downloadBtn} href={href} download={filename}>
|
||||
download
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<pre className={styles.textPreview}>{content}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionStringArtifact({
|
||||
artifact,
|
||||
}: {
|
||||
artifact: Extract<Artifact, { kind: 'connection_string' }>
|
||||
}): React.ReactElement {
|
||||
const value = artifact.connection_string ?? ''
|
||||
return (
|
||||
<div className={styles.artifactStack}>
|
||||
<CopyField label="CONNECTION" value={value} fullWidth />
|
||||
<p className={styles.note}>
|
||||
Open in any MySQL client. The handshake will trip the trap.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function base64ToObjectUrl(b64: string, contentType: string): string {
|
||||
const binary = atob(b64)
|
||||
const len = binary.length
|
||||
const bytes = new Uint8Array(len)
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return URL.createObjectURL(new Blob([bytes], { type: contentType }))
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// copy.ts
|
||||
// ===================
|
||||
|
||||
import type { TokenType } from '@/api'
|
||||
|
||||
export const PAGE_COPY = {
|
||||
HEADLINE: 'CANARIES',
|
||||
HEADLINE_LATIN: 'Serinus Canaria · token-form (n.)',
|
||||
HEADLINE_PURPOSE:
|
||||
'Field-deployable trip-wires for the quiet hours. Each specimen waits in place, reports when touched, and leaves a forensic note behind.',
|
||||
STRIP_FIELD_STATION: 'FIELD STATION',
|
||||
STRIP_ISSUE: 'ISSUE',
|
||||
STRIP_VOLUME: 'VOL.001',
|
||||
STRIP_FOLIO: 'FOLIO',
|
||||
SECTION_01_INDEX: '01 / SPECIES',
|
||||
SECTION_01_TITLE: 'SELECT SPECIES',
|
||||
SECTION_01_BODY:
|
||||
'Each type lives in a different habitat. Pick the one that fits where the trap will be set.',
|
||||
SECTION_02_INDEX: '02 / FIELD LABEL',
|
||||
SECTION_02_TITLE: 'ANNOTATE THE SPECIMEN',
|
||||
SECTION_02_BODY:
|
||||
'A short note describing where this specimen will be deployed. You will see it again when the trap reports.',
|
||||
SECTION_03_INDEX: '03 / ALERT ROUTE',
|
||||
SECTION_03_TITLE: 'ROUTE THE REPORT',
|
||||
SECTION_03_BODY:
|
||||
'When the trap is touched, where should the message land? Choose one channel.',
|
||||
SECTION_04_INDEX: '04 / SPECIES CONFIG',
|
||||
SECTION_04_BODY: 'Configuration specific to the chosen species.',
|
||||
SECTION_05_INDEX: '05 / VERIFY',
|
||||
SECTION_05_BODY:
|
||||
'A one-touch human check from Cloudflare. Skipped in development.',
|
||||
SUBMIT_LABEL: 'Release specimen',
|
||||
ISSUE_ANOTHER_LABEL: 'Release another',
|
||||
RESULT_HEADLINE: 'SPECIMEN ISSUED',
|
||||
RESULT_BODY:
|
||||
'The trap is live. Keep the manage URL safe — it is the only way back to this dossier.',
|
||||
} as const
|
||||
|
||||
export const TOKEN_BLURB: Record<TokenType, string> = {
|
||||
webbug:
|
||||
'1×1 transparent pixel. Embed anywhere an <img> works — emails, wiki pages, slide decks.',
|
||||
slowredirect:
|
||||
'A redirect that pauses to fingerprint the visitor before delivering them. Drop-in short link replacement.',
|
||||
docx: 'A Word document that phones home the moment Word renders its footer.',
|
||||
pdf: 'A PDF whose embedded URI action fires in Adobe Reader. Quiet against Chromium / PDF.js.',
|
||||
kubeconfig:
|
||||
'A kubeconfig pointing at a fake Kubernetes API. kubectl calls trigger and are recorded with their verb + path.',
|
||||
envfile:
|
||||
'A plausible-looking .env file with bait credentials and one disguised canary URL.',
|
||||
mysql:
|
||||
'A fake MySQL server that answers the handshake, captures the username, then 1045 ACCESS DENIED.',
|
||||
}
|
||||
|
||||
export const ARTIFACT_LABEL = {
|
||||
url: 'TRIGGER URL',
|
||||
file: 'DOWNLOAD',
|
||||
text: 'CONTENT',
|
||||
connection_string: 'CONNECTION STRING',
|
||||
} as const
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// form-state.ts
|
||||
// ===================
|
||||
|
||||
import {
|
||||
type CreateTokenInput,
|
||||
createTokenRequestSchema,
|
||||
type EnvfileIncludeKey,
|
||||
type TokenType,
|
||||
type TypeDescriptor,
|
||||
} from '@/api'
|
||||
import { ApiError } from '@/core/api'
|
||||
|
||||
export type FormState = {
|
||||
type: TokenType | ''
|
||||
memo: string
|
||||
filename: string
|
||||
alertChannel: 'telegram' | 'webhook'
|
||||
telegramBot: string
|
||||
telegramChat: string
|
||||
webhookUrl: string
|
||||
slowredirectDestination: string
|
||||
envfileIncludeKeys: EnvfileIncludeKey[]
|
||||
}
|
||||
|
||||
export const INITIAL_FORM: FormState = {
|
||||
type: '',
|
||||
memo: '',
|
||||
filename: '',
|
||||
alertChannel: 'telegram',
|
||||
telegramBot: '',
|
||||
telegramChat: '',
|
||||
webhookUrl: '',
|
||||
slowredirectDestination: '',
|
||||
envfileIncludeKeys: ['aws', 'stripe', 'db'],
|
||||
}
|
||||
|
||||
export const FILE_KIND_TYPES = new Set<TokenType>([
|
||||
'docx',
|
||||
'pdf',
|
||||
'kubeconfig',
|
||||
'envfile',
|
||||
])
|
||||
|
||||
export function descriptorFor(
|
||||
descriptors: TypeDescriptor[],
|
||||
type: TokenType | ''
|
||||
): TypeDescriptor | undefined {
|
||||
if (type === '') {
|
||||
return undefined
|
||||
}
|
||||
return descriptors.find((d) => d.type === type)
|
||||
}
|
||||
|
||||
export function buildPayload(form: FormState): CreateTokenInput {
|
||||
if (form.type === '') {
|
||||
throw new Error('species not selected')
|
||||
}
|
||||
const payload: CreateTokenInput = {
|
||||
type: form.type,
|
||||
memo: form.memo,
|
||||
alert_channel: form.alertChannel,
|
||||
}
|
||||
if (form.filename.length > 0) {
|
||||
payload.filename = form.filename
|
||||
}
|
||||
if (form.alertChannel === 'telegram') {
|
||||
payload.telegram_bot = form.telegramBot
|
||||
payload.telegram_chat = form.telegramChat
|
||||
} else {
|
||||
payload.webhook_url = form.webhookUrl
|
||||
}
|
||||
if (form.type === 'slowredirect') {
|
||||
payload.metadata = { destination_url: form.slowredirectDestination }
|
||||
} else if (form.type === 'envfile') {
|
||||
payload.metadata = { include_keys: form.envfileIncludeKeys }
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
export type FieldErrors = Partial<Record<string, string>>
|
||||
|
||||
const BACKEND_TO_FORM_FIELD: Readonly<Record<string, string>> = {
|
||||
type: 'type',
|
||||
memo: 'memo',
|
||||
filename: 'filename',
|
||||
alert_channel: 'alert_channel',
|
||||
telegram_bot: 'telegram_bot',
|
||||
telegram_chat: 'telegram_chat',
|
||||
webhook_url: 'webhook_url',
|
||||
'metadata.destination_url': 'metadata.destination_url',
|
||||
'metadata.include_keys': 'metadata.include_keys',
|
||||
}
|
||||
|
||||
export function buildSubmissionState(error: unknown): {
|
||||
errors: FieldErrors
|
||||
} {
|
||||
if (!(error instanceof ApiError) || !error.fields) {
|
||||
return { errors: { form: 'submission failed — try again' } }
|
||||
}
|
||||
const errors: FieldErrors = {}
|
||||
for (const [key, value] of Object.entries(error.fields)) {
|
||||
const mapped = BACKEND_TO_FORM_FIELD[key] ?? key
|
||||
errors[mapped] = value
|
||||
}
|
||||
return { errors }
|
||||
}
|
||||
|
||||
export function validateForm(form: FormState): {
|
||||
ok: boolean
|
||||
errors: FieldErrors
|
||||
payload: CreateTokenInput | null
|
||||
} {
|
||||
if (form.type === '') {
|
||||
return { ok: false, errors: { type: 'select a species' }, payload: null }
|
||||
}
|
||||
let payload: CreateTokenInput
|
||||
try {
|
||||
payload = buildPayload(form)
|
||||
} catch (_err) {
|
||||
return { ok: false, errors: { type: 'select a species' }, payload: null }
|
||||
}
|
||||
const parsed = createTokenRequestSchema.safeParse(payload)
|
||||
if (parsed.success) {
|
||||
return { ok: true, errors: {}, payload }
|
||||
}
|
||||
const errors: FieldErrors = {}
|
||||
for (const issue of parsed.error.issues) {
|
||||
const key = issue.path.join('.') || 'form'
|
||||
if (errors[key] === undefined) {
|
||||
errors[key] = issue.message
|
||||
}
|
||||
}
|
||||
return { ok: false, errors, payload: null }
|
||||
}
|
||||
|
|
@ -3,8 +3,611 @@
|
|||
// index.tsx
|
||||
// ===================
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
type CreateTokenResponse,
|
||||
type EnvfileIncludeKey,
|
||||
envfileIncludeKeySchema,
|
||||
type TokenType,
|
||||
type TypeDescriptor,
|
||||
useCreateToken,
|
||||
useTokenTypes,
|
||||
} from '@/api'
|
||||
import { Button, Halftone, Strip, StripItem } from '@/components'
|
||||
import { getTurnstileSiteKey, Turnstile } from '@/core/turnstile'
|
||||
import { ArtifactDisplay } from './artifact'
|
||||
import { PAGE_COPY, TOKEN_BLURB } from './copy'
|
||||
import {
|
||||
buildSubmissionState,
|
||||
FILE_KIND_TYPES,
|
||||
type FieldErrors,
|
||||
type FormState,
|
||||
INITIAL_FORM,
|
||||
validateForm,
|
||||
} from './form-state'
|
||||
import styles from './landing.module.scss'
|
||||
import { ResultView } from './result'
|
||||
import { TypePicker } from './type-picker'
|
||||
|
||||
const ENVFILE_KEYS = envfileIncludeKeySchema.options
|
||||
|
||||
export function Component(): React.ReactElement {
|
||||
return <div />
|
||||
const typesQuery = useTokenTypes()
|
||||
const createMutation = useCreateToken()
|
||||
const [form, setForm] = useState<FormState>(INITIAL_FORM)
|
||||
const [errors, setErrors] = useState<FieldErrors>({})
|
||||
const [created, setCreated] = useState<CreateTokenResponse | null>(null)
|
||||
const turnstileSiteKey = useMemo(() => getTurnstileSiteKey(), [])
|
||||
|
||||
function patchForm(next: Partial<FormState>): void {
|
||||
setForm((prev) => ({ ...prev, ...next }))
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>): void {
|
||||
event.preventDefault()
|
||||
const result = validateForm(form)
|
||||
setErrors(result.errors)
|
||||
if (!result.ok || !result.payload) {
|
||||
toast.error('Some fields need attention')
|
||||
return
|
||||
}
|
||||
createMutation.mutate(result.payload, {
|
||||
onSuccess: (data) => {
|
||||
setCreated(data)
|
||||
toast.success('Specimen released')
|
||||
},
|
||||
onError: (error) => {
|
||||
const submission = buildSubmissionState(error)
|
||||
setErrors(submission.errors)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleAnother(): void {
|
||||
setCreated(null)
|
||||
setErrors({})
|
||||
setForm((prev) => ({ ...prev, memo: '', filename: '' }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Strip>
|
||||
<StripItem label={PAGE_COPY.STRIP_FIELD_STATION}>canary</StripItem>
|
||||
<StripItem label={PAGE_COPY.STRIP_VOLUME}>2026·05</StripItem>
|
||||
<StripItem label={PAGE_COPY.STRIP_ISSUE} inverted>
|
||||
{created ? 'DOSSIER' : 'INTAKE'}
|
||||
</StripItem>
|
||||
</Strip>
|
||||
|
||||
<header className={styles.hero}>
|
||||
<h1 className={styles.headline}>{PAGE_COPY.HEADLINE}</h1>
|
||||
<p className={styles.latin}>{PAGE_COPY.HEADLINE_LATIN}</p>
|
||||
<p className={styles.purpose}>{PAGE_COPY.HEADLINE_PURPOSE}</p>
|
||||
</header>
|
||||
|
||||
<Halftone density="sparse" height={18} />
|
||||
|
||||
<main className={styles.main}>
|
||||
{created ? (
|
||||
<ResultView
|
||||
data={created}
|
||||
onAnother={handleAnother}
|
||||
filenameFallback={form.filename || defaultFilename(form.type)}
|
||||
/>
|
||||
) : (
|
||||
<FormView
|
||||
form={form}
|
||||
errors={errors}
|
||||
descriptors={typesQuery.data ?? []}
|
||||
descriptorsLoading={typesQuery.isLoading}
|
||||
descriptorsError={typesQuery.error}
|
||||
turnstileSiteKey={turnstileSiteKey}
|
||||
submitting={createMutation.isPending}
|
||||
onChange={patchForm}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Strip align="left" border="top">
|
||||
<StripItem label="ROUTING">cloudflare tunnel</StripItem>
|
||||
<StripItem label="ALERTS">telegram / webhook</StripItem>
|
||||
<StripItem label="LICENSE">©AngelaMos 2026</StripItem>
|
||||
</Strip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Component.displayName = 'Landing'
|
||||
|
||||
type FormViewProps = {
|
||||
form: FormState
|
||||
errors: FieldErrors
|
||||
descriptors: TypeDescriptor[]
|
||||
descriptorsLoading: boolean
|
||||
descriptorsError: Error | null
|
||||
turnstileSiteKey: string | null
|
||||
submitting: boolean
|
||||
onChange: (next: Partial<FormState>) => void
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void
|
||||
}
|
||||
|
||||
function FormView({
|
||||
form,
|
||||
errors,
|
||||
descriptors,
|
||||
descriptorsLoading,
|
||||
descriptorsError,
|
||||
turnstileSiteKey,
|
||||
submitting,
|
||||
onChange,
|
||||
onSubmit,
|
||||
}: FormViewProps): React.ReactElement {
|
||||
const showTypeMetadata = form.type === 'slowredirect' || form.type === 'envfile'
|
||||
return (
|
||||
<form className={styles.form} onSubmit={onSubmit} noValidate>
|
||||
<SpeciesSection
|
||||
form={form}
|
||||
errors={errors}
|
||||
descriptors={descriptors}
|
||||
descriptorsLoading={descriptorsLoading}
|
||||
descriptorsError={descriptorsError}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<AnnotateSection form={form} errors={errors} onChange={onChange} />
|
||||
<RouteSection form={form} errors={errors} onChange={onChange} />
|
||||
{showTypeMetadata ? (
|
||||
<ConfigSection form={form} errors={errors} onChange={onChange} />
|
||||
) : null}
|
||||
{turnstileSiteKey ? <VerifySection siteKey={turnstileSiteKey} /> : null}
|
||||
<SubmitFooter submitting={submitting} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
type SectionPropsBase = {
|
||||
form: FormState
|
||||
errors: FieldErrors
|
||||
onChange: (next: Partial<FormState>) => void
|
||||
}
|
||||
|
||||
type SpeciesSectionProps = SectionPropsBase & {
|
||||
descriptors: TypeDescriptor[]
|
||||
descriptorsLoading: boolean
|
||||
descriptorsError: Error | null
|
||||
}
|
||||
|
||||
function SpeciesSection({
|
||||
form,
|
||||
errors,
|
||||
descriptors,
|
||||
descriptorsLoading,
|
||||
descriptorsError,
|
||||
onChange,
|
||||
}: SpeciesSectionProps): React.ReactElement {
|
||||
return (
|
||||
<Section
|
||||
index={PAGE_COPY.SECTION_01_INDEX}
|
||||
title={PAGE_COPY.SECTION_01_TITLE}
|
||||
body={PAGE_COPY.SECTION_01_BODY}
|
||||
>
|
||||
{descriptorsError ? (
|
||||
<p className={styles.errorBlock}>
|
||||
Could not load species catalog. Try again in a moment.
|
||||
</p>
|
||||
) : descriptorsLoading ? (
|
||||
<p className={styles.statusBlock}>Loading species catalog…</p>
|
||||
) : (
|
||||
<TypePicker
|
||||
descriptors={descriptors}
|
||||
value={form.type}
|
||||
onChange={(t) => onChange({ type: t })}
|
||||
invalid={errors.type !== undefined}
|
||||
/>
|
||||
)}
|
||||
{errors.type ? <p className={styles.fieldError}>{errors.type}</p> : null}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function AnnotateSection({
|
||||
form,
|
||||
errors,
|
||||
onChange,
|
||||
}: SectionPropsBase): React.ReactElement {
|
||||
const showFilename =
|
||||
form.type !== '' && FILE_KIND_TYPES.has(form.type as TokenType)
|
||||
return (
|
||||
<Section
|
||||
index={PAGE_COPY.SECTION_02_INDEX}
|
||||
title={PAGE_COPY.SECTION_02_TITLE}
|
||||
body={PAGE_COPY.SECTION_02_BODY}
|
||||
>
|
||||
<div className={styles.fieldRow}>
|
||||
<FieldLabel index="A">MEMO</FieldLabel>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={form.memo}
|
||||
maxLength={256}
|
||||
placeholder="Q4 bonuses spreadsheet on file-server"
|
||||
onChange={(e) => onChange({ memo: e.target.value })}
|
||||
data-invalid={errors.memo !== undefined}
|
||||
/>
|
||||
{errors.memo ? (
|
||||
<p className={styles.fieldError}>{errors.memo}</p>
|
||||
) : (
|
||||
<p className={styles.fieldHint}>{form.memo.length} / 256 characters</p>
|
||||
)}
|
||||
</div>
|
||||
{showFilename ? (
|
||||
<FilenameField form={form} errors={errors} onChange={onChange} />
|
||||
) : null}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function FilenameField({
|
||||
form,
|
||||
errors,
|
||||
onChange,
|
||||
}: SectionPropsBase): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.fieldRow}>
|
||||
<FieldLabel index="B">FILENAME (OPTIONAL)</FieldLabel>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={form.filename}
|
||||
maxLength={128}
|
||||
placeholder={defaultFilename(form.type)}
|
||||
onChange={(e) => onChange({ filename: e.target.value })}
|
||||
data-invalid={errors.filename !== undefined}
|
||||
/>
|
||||
{errors.filename ? (
|
||||
<p className={styles.fieldError}>{errors.filename}</p>
|
||||
) : (
|
||||
<p className={styles.fieldHint}>
|
||||
What the bait file will be called when it lands somewhere it shouldn't.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RouteSection({
|
||||
form,
|
||||
errors,
|
||||
onChange,
|
||||
}: SectionPropsBase): React.ReactElement {
|
||||
return (
|
||||
<Section
|
||||
index={PAGE_COPY.SECTION_03_INDEX}
|
||||
title="ROUTE THE REPORT"
|
||||
body={PAGE_COPY.SECTION_03_BODY}
|
||||
>
|
||||
<ChannelToggle
|
||||
value={form.alertChannel}
|
||||
onChange={(c) => onChange({ alertChannel: c })}
|
||||
/>
|
||||
{form.alertChannel === 'telegram' ? (
|
||||
<TelegramFields form={form} errors={errors} onChange={onChange} />
|
||||
) : (
|
||||
<WebhookField form={form} errors={errors} onChange={onChange} />
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function TelegramFields({
|
||||
form,
|
||||
errors,
|
||||
onChange,
|
||||
}: SectionPropsBase): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.subgrid}>
|
||||
<div className={styles.fieldRow}>
|
||||
<FieldLabel index="A">BOT TOKEN</FieldLabel>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={form.telegramBot}
|
||||
placeholder="123456789:ABCdefGHIjklMNO..."
|
||||
onChange={(e) => onChange({ telegramBot: e.target.value })}
|
||||
data-invalid={errors.telegram_bot !== undefined}
|
||||
/>
|
||||
{errors.telegram_bot ? (
|
||||
<p className={styles.fieldError}>{errors.telegram_bot}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.fieldRow}>
|
||||
<FieldLabel index="B">CHAT ID</FieldLabel>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={form.telegramChat}
|
||||
placeholder="-1001234567890"
|
||||
onChange={(e) => onChange({ telegramChat: e.target.value })}
|
||||
data-invalid={errors.telegram_chat !== undefined}
|
||||
/>
|
||||
{errors.telegram_chat ? (
|
||||
<p className={styles.fieldError}>{errors.telegram_chat}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WebhookField({
|
||||
form,
|
||||
errors,
|
||||
onChange,
|
||||
}: SectionPropsBase): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.fieldRow}>
|
||||
<FieldLabel index="A">WEBHOOK URL</FieldLabel>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="url"
|
||||
value={form.webhookUrl}
|
||||
placeholder="https://hooks.example.com/canary"
|
||||
onChange={(e) => onChange({ webhookUrl: e.target.value })}
|
||||
data-invalid={errors.webhook_url !== undefined}
|
||||
/>
|
||||
{errors.webhook_url ? (
|
||||
<p className={styles.fieldError}>{errors.webhook_url}</p>
|
||||
) : (
|
||||
<p className={styles.fieldHint}>
|
||||
We POST the alert envelope JSON. Optionally HMAC-signed if the server
|
||||
has WEBHOOK_HMAC_SECRET set.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigSection({
|
||||
form,
|
||||
errors,
|
||||
onChange,
|
||||
}: SectionPropsBase): React.ReactElement {
|
||||
const body =
|
||||
form.type === 'slowredirect'
|
||||
? 'Visitors will be fingerprinted, then redirected here.'
|
||||
: 'Bait credentials to ship alongside the disguised canary URL.'
|
||||
return (
|
||||
<Section
|
||||
index={PAGE_COPY.SECTION_04_INDEX}
|
||||
title={descriptorTitle(form.type)}
|
||||
body={body}
|
||||
>
|
||||
{form.type === 'slowredirect' ? (
|
||||
<SlowRedirectField form={form} errors={errors} onChange={onChange} />
|
||||
) : (
|
||||
<IncludeKeysPicker
|
||||
value={form.envfileIncludeKeys}
|
||||
onChange={(k) => onChange({ envfileIncludeKeys: k })}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function SlowRedirectField({
|
||||
form,
|
||||
errors,
|
||||
onChange,
|
||||
}: SectionPropsBase): React.ReactElement {
|
||||
const err = errors['metadata.destination_url']
|
||||
return (
|
||||
<div className={styles.fieldRow}>
|
||||
<FieldLabel index="A">DESTINATION URL</FieldLabel>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="url"
|
||||
value={form.slowredirectDestination}
|
||||
placeholder="https://example.com/landing"
|
||||
onChange={(e) => onChange({ slowredirectDestination: e.target.value })}
|
||||
data-invalid={err !== undefined}
|
||||
/>
|
||||
{err ? <p className={styles.fieldError}>{err}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VerifySection({ siteKey }: { siteKey: string }): React.ReactElement {
|
||||
return (
|
||||
<Section
|
||||
index={PAGE_COPY.SECTION_05_INDEX}
|
||||
title="VERIFY"
|
||||
body={PAGE_COPY.SECTION_05_BODY}
|
||||
>
|
||||
<div className={styles.turnstile}>
|
||||
<Turnstile siteKey={siteKey} />
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function SubmitFooter({
|
||||
submitting,
|
||||
}: {
|
||||
submitting: boolean
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.submitRow}>
|
||||
<p className={styles.submitNote}>
|
||||
We never see the alert payload — it routes directly from server to your
|
||||
channel.
|
||||
</p>
|
||||
<Button type="submit" size="lg" busy={submitting}>
|
||||
{PAGE_COPY.SUBMIT_LABEL}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type SectionProps = React.PropsWithChildren<{
|
||||
index: string
|
||||
title: string
|
||||
body: string
|
||||
}>
|
||||
|
||||
function Section({
|
||||
index,
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: SectionProps): React.ReactElement {
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<header className={styles.sectionHead}>
|
||||
<span className={styles.sectionIndex}>{index}</span>
|
||||
<span className={styles.sectionRule} aria-hidden="true" />
|
||||
</header>
|
||||
<h2 className={styles.sectionTitle}>{title}</h2>
|
||||
<p className={styles.sectionBody}>{body}</p>
|
||||
<div className={styles.sectionContent}>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
index,
|
||||
children,
|
||||
}: React.PropsWithChildren<{ index?: string }>): React.ReactElement {
|
||||
return (
|
||||
<div className={styles.fieldLabel}>
|
||||
{index ? <span className={styles.fieldIndex}>{index}</span> : null}
|
||||
<span className={styles.fieldName}>{children}</span>
|
||||
<span className={styles.fieldRule} aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ChannelToggleProps = {
|
||||
value: 'telegram' | 'webhook'
|
||||
onChange: (next: 'telegram' | 'webhook') => void
|
||||
}
|
||||
|
||||
function ChannelToggle({
|
||||
value,
|
||||
onChange,
|
||||
}: ChannelToggleProps): React.ReactElement {
|
||||
return (
|
||||
<fieldset className={styles.channelToggle}>
|
||||
<legend className={styles.srOnly}>Alert channel</legend>
|
||||
<ChannelOption
|
||||
name="alert-channel"
|
||||
value="telegram"
|
||||
active={value === 'telegram'}
|
||||
onChange={() => onChange('telegram')}
|
||||
label="Telegram"
|
||||
note="message lands in a chat"
|
||||
/>
|
||||
<ChannelOption
|
||||
name="alert-channel"
|
||||
value="webhook"
|
||||
active={value === 'webhook'}
|
||||
onChange={() => onChange('webhook')}
|
||||
label="Webhook"
|
||||
note="JSON POST to your URL"
|
||||
/>
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelOption({
|
||||
name,
|
||||
value,
|
||||
active,
|
||||
onChange,
|
||||
label,
|
||||
note,
|
||||
}: {
|
||||
name: string
|
||||
value: string
|
||||
active: boolean
|
||||
onChange: () => void
|
||||
label: string
|
||||
note: string
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<label className={styles.channelOption} data-active={active}>
|
||||
<input
|
||||
className={styles.srOnly}
|
||||
type="radio"
|
||||
name={name}
|
||||
value={value}
|
||||
checked={active}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<span className={styles.channelLabel}>{label}</span>
|
||||
<span className={styles.channelNote}>{note}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
type IncludeKeysPickerProps = {
|
||||
value: EnvfileIncludeKey[]
|
||||
onChange: (next: EnvfileIncludeKey[]) => void
|
||||
}
|
||||
|
||||
function IncludeKeysPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: IncludeKeysPickerProps): React.ReactElement {
|
||||
function toggle(key: EnvfileIncludeKey): void {
|
||||
if (value.includes(key)) {
|
||||
onChange(value.filter((k) => k !== key))
|
||||
} else {
|
||||
onChange([...value, key])
|
||||
}
|
||||
}
|
||||
return (
|
||||
<fieldset className={styles.keyGrid}>
|
||||
<legend className={styles.srOnly}>Include keys</legend>
|
||||
{ENVFILE_KEYS.map((key) => {
|
||||
const checked = value.includes(key)
|
||||
return (
|
||||
<label key={key} className={styles.keyChip} data-selected={checked}>
|
||||
<input
|
||||
className={styles.srOnly}
|
||||
type="checkbox"
|
||||
name="envfile-keys"
|
||||
value={key}
|
||||
checked={checked}
|
||||
onChange={() => toggle(key)}
|
||||
/>
|
||||
<span className={styles.keyName}>{key}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
function defaultFilename(type: TokenType | ''): string {
|
||||
switch (type) {
|
||||
case 'docx':
|
||||
return 'Q4_Bonuses_2024.docx'
|
||||
case 'pdf':
|
||||
return 'Confidential_Report.pdf'
|
||||
case 'kubeconfig':
|
||||
return 'kubeconfig'
|
||||
case 'envfile':
|
||||
return '.env'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function descriptorTitle(type: TokenType | ''): string {
|
||||
if (type === 'slowredirect') {
|
||||
return 'SLOWREDIRECT CONFIG'
|
||||
}
|
||||
if (type === 'envfile') {
|
||||
return 'ENVFILE CONFIG'
|
||||
}
|
||||
return 'CONFIG'
|
||||
}
|
||||
|
||||
export { TOKEN_BLURB }
|
||||
export { ArtifactDisplay }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,669 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// landing.module.scss
|
||||
// ===================
|
||||
|
||||
@use '../../styles/tokens' as *;
|
||||
@use '../../styles/fonts' as *;
|
||||
@use '../../styles/mixins' as *;
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
inline-size: 100%;
|
||||
max-inline-size: 88rem;
|
||||
margin-inline: auto;
|
||||
padding-inline: $space-4;
|
||||
padding-block-end: $space-8;
|
||||
gap: $space-6;
|
||||
|
||||
@include breakpoint-up('md') {
|
||||
padding-inline: $space-8;
|
||||
gap: $space-8;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
padding-block-start: $space-6;
|
||||
max-inline-size: 56rem;
|
||||
|
||||
@include breakpoint-up('md') {
|
||||
padding-block-start: $space-10;
|
||||
}
|
||||
}
|
||||
|
||||
.headline {
|
||||
font-family: $font-sans;
|
||||
font-weight: 900;
|
||||
font-size: clamp(3rem, 11vw, 8.5rem);
|
||||
letter-spacing: -0.06em;
|
||||
line-height: 0.85;
|
||||
color: $ink;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.latin {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
color: $ink-soft;
|
||||
margin: 0;
|
||||
text-transform: lowercase;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.purpose {
|
||||
font-family: $font-sans;
|
||||
font-size: clamp(0.95rem, 1.6vw, 1.125rem);
|
||||
line-height: $line-height-snug;
|
||||
color: $ink;
|
||||
max-inline-size: 42rem;
|
||||
margin: 0;
|
||||
margin-block-start: $space-3;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
gap: $space-8;
|
||||
padding-block-start: $space-2;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-10;
|
||||
max-inline-size: 56rem;
|
||||
inline-size: 100%;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.sectionHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-3;
|
||||
}
|
||||
|
||||
.sectionIndex {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink-mute;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sectionRule {
|
||||
flex: 1 1 auto;
|
||||
block-size: $hairline;
|
||||
background-color: $rule-hair;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-family: $font-sans;
|
||||
font-weight: 800;
|
||||
font-size: clamp(1.5rem, 3vw, 2.25rem);
|
||||
letter-spacing: -0.03em;
|
||||
line-height: $line-height-tight;
|
||||
text-transform: uppercase;
|
||||
color: $ink;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sectionBody {
|
||||
font-family: $font-sans;
|
||||
font-size: $font-size-sm;
|
||||
line-height: $line-height-snug;
|
||||
color: $ink-soft;
|
||||
max-inline-size: 38rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-5;
|
||||
padding-block-start: $space-3;
|
||||
}
|
||||
|
||||
.typeGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: $space-3;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-inline-size: 0;
|
||||
|
||||
&[data-invalid='true'] {
|
||||
outline: $hairline solid $alarm;
|
||||
outline-offset: $space-2;
|
||||
}
|
||||
|
||||
@include breakpoint-up('sm') {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@include breakpoint-up('md') {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@include breakpoint-up('lg') {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.srOnly {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.typeCard {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'idx glyph'
|
||||
'name name'
|
||||
'code code'
|
||||
'blur blur';
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: start;
|
||||
gap: $space-2 $space-3;
|
||||
padding: $space-4;
|
||||
background-color: $paper;
|
||||
border: $hairline solid $rule;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color $duration-fast $ease-out,
|
||||
color $duration-fast $ease-out;
|
||||
|
||||
&:hover {
|
||||
background-color: $paper-deep;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: $hairline-bold solid $ink;
|
||||
outline-offset: $space-1;
|
||||
}
|
||||
|
||||
&[data-selected='true'] {
|
||||
background-color: $ink;
|
||||
color: $paper;
|
||||
border-color: $ink;
|
||||
|
||||
.typeIndex,
|
||||
.typeCode {
|
||||
color: $paper-edge;
|
||||
}
|
||||
|
||||
.typeBlurb {
|
||||
color: $paper-edge;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.typeIndex {
|
||||
grid-area: idx;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
color: $ink-mute;
|
||||
}
|
||||
|
||||
.typeGlyph {
|
||||
grid-area: glyph;
|
||||
justify-self: end;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.typeName {
|
||||
grid-area: name;
|
||||
font-family: $font-sans;
|
||||
font-weight: 700;
|
||||
font-size: $font-size-lg;
|
||||
line-height: $line-height-tight;
|
||||
letter-spacing: -0.02em;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.typeCode {
|
||||
grid-area: code;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink-mute;
|
||||
}
|
||||
|
||||
.typeBlurb {
|
||||
grid-area: blur;
|
||||
font-size: $font-size-xs;
|
||||
line-height: $line-height-snug;
|
||||
color: $ink-soft;
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.fieldIndex {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
color: $ink-mute;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fieldName {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
font-weight: $font-weight-semibold;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fieldRule {
|
||||
flex: 1 1 auto;
|
||||
block-size: $hairline;
|
||||
background-color: $rule-hair;
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea {
|
||||
inline-size: 100%;
|
||||
padding-block: $space-3;
|
||||
padding-inline: $space-3;
|
||||
border: $hairline solid $rule;
|
||||
background-color: $paper;
|
||||
font-family: $font-sans;
|
||||
font-size: $font-size-base;
|
||||
color: $ink;
|
||||
appearance: none;
|
||||
border-radius: 0;
|
||||
transition: border-color $duration-fast $ease-out;
|
||||
|
||||
&::placeholder {
|
||||
color: $ink-faint;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 $hairline $ink;
|
||||
border-color: $ink;
|
||||
}
|
||||
|
||||
&[data-invalid='true'] {
|
||||
border-color: $alarm;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-block-size: 5lh;
|
||||
resize: vertical;
|
||||
line-height: $line-height-normal;
|
||||
}
|
||||
|
||||
.fieldHint {
|
||||
font-size: $font-size-xs;
|
||||
color: $ink-mute;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $alarm;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.errorBlock,
|
||||
.statusBlock {
|
||||
padding: $space-4;
|
||||
border: $hairline solid $rule;
|
||||
background-color: $paper-deep;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
letter-spacing: $tracking-wide;
|
||||
color: $ink-soft;
|
||||
}
|
||||
|
||||
.errorBlock {
|
||||
border-color: $alarm;
|
||||
color: $alarm-deep;
|
||||
}
|
||||
|
||||
.channelToggle {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0;
|
||||
border: $hairline solid $rule;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
.channelOption {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $space-1;
|
||||
padding-block: $space-3;
|
||||
padding-inline: $space-4;
|
||||
background-color: $paper;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background-color $duration-fast $ease-out;
|
||||
|
||||
&:hover {
|
||||
background-color: $paper-deep;
|
||||
}
|
||||
|
||||
& + & {
|
||||
border-inline-start: $hairline solid $rule;
|
||||
}
|
||||
|
||||
&[data-active='true'] {
|
||||
background-color: $ink;
|
||||
color: $paper;
|
||||
}
|
||||
}
|
||||
|
||||
.channelLabel {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-2xs;
|
||||
font-weight: $font-weight-semibold;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.channelNote {
|
||||
font-size: $font-size-xs;
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.subgrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: $space-4;
|
||||
|
||||
@include breakpoint-up('md') {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.keyGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: $space-2;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-inline-size: 0;
|
||||
|
||||
@include breakpoint-up('sm') {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.keyChip {
|
||||
padding-block: $space-3;
|
||||
padding-inline: $space-3;
|
||||
border: $hairline solid $rule;
|
||||
background-color: $paper;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-2xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink;
|
||||
cursor: pointer;
|
||||
transition: background-color $duration-fast $ease-out;
|
||||
|
||||
&:hover {
|
||||
background-color: $paper-deep;
|
||||
}
|
||||
|
||||
&[data-selected='true'] {
|
||||
background-color: $ink;
|
||||
color: $paper;
|
||||
border-color: $ink;
|
||||
}
|
||||
}
|
||||
|
||||
.keyName {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.turnstile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $space-3;
|
||||
border: $hairline solid $rule-hair;
|
||||
background-color: $paper-deep;
|
||||
}
|
||||
|
||||
.submitRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-3;
|
||||
align-items: stretch;
|
||||
padding-block-start: $space-4;
|
||||
border-block-start: $hairline solid $rule;
|
||||
|
||||
@include breakpoint-up('md') {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.submitNote {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wide;
|
||||
text-transform: uppercase;
|
||||
color: $ink-mute;
|
||||
margin: 0;
|
||||
max-inline-size: 28rem;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// RESULT
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
.result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-6;
|
||||
max-inline-size: 56rem;
|
||||
}
|
||||
|
||||
.resultHead {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
align-items: baseline;
|
||||
gap: $space-2 $space-3;
|
||||
}
|
||||
|
||||
.resultMarker {
|
||||
grid-row: 1 / span 2;
|
||||
font-size: clamp(2.5rem, 5vw, 4rem);
|
||||
line-height: 1;
|
||||
color: $alarm;
|
||||
align-self: start;
|
||||
font-family: $font-sans;
|
||||
}
|
||||
|
||||
.resultHeadline {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
font-family: $font-sans;
|
||||
font-weight: 900;
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.9;
|
||||
text-transform: uppercase;
|
||||
color: $ink;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.resultBody {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
font-family: $font-sans;
|
||||
font-size: $font-size-sm;
|
||||
line-height: $line-height-snug;
|
||||
color: $ink-soft;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.resultFoot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $space-3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.resultLink {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink;
|
||||
text-decoration: none;
|
||||
border-block-end: $hairline solid $ink;
|
||||
padding-block-end: $space-0-5;
|
||||
transition: color $duration-fast $ease-out;
|
||||
|
||||
&:hover {
|
||||
color: $alarm;
|
||||
border-block-end-color: $alarm;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// ARTIFACT
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
.artifactStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.fileBar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: stretch;
|
||||
border: $hairline solid $rule;
|
||||
background-color: $paper-deep;
|
||||
}
|
||||
|
||||
.fileMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $space-1;
|
||||
padding-block: $space-3;
|
||||
padding-inline: $space-3;
|
||||
}
|
||||
|
||||
.fileLabel {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
color: $ink-mute;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-sm;
|
||||
color: $ink;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fileType {
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-3xs;
|
||||
letter-spacing: $tracking-wide;
|
||||
color: $ink-soft;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.downloadBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-block: $space-3;
|
||||
padding-inline: $space-5;
|
||||
background-color: $ink;
|
||||
color: $paper;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-2xs;
|
||||
letter-spacing: $tracking-wider;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
border-inline-start: $hairline solid $ink;
|
||||
transition: background-color $duration-fast $ease-out,
|
||||
color $duration-fast $ease-out;
|
||||
|
||||
&:hover {
|
||||
background-color: $paper;
|
||||
color: $ink;
|
||||
}
|
||||
}
|
||||
|
||||
.textPreview {
|
||||
margin: 0;
|
||||
padding: $space-3;
|
||||
max-block-size: 24rem;
|
||||
overflow: auto;
|
||||
background-color: $paper-deep;
|
||||
border: $hairline solid $rule-hair;
|
||||
font-family: $font-mono;
|
||||
font-size: $font-size-xs;
|
||||
line-height: $line-height-snug;
|
||||
color: $ink;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.note {
|
||||
font-size: $font-size-xs;
|
||||
color: $ink-soft;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// result.tsx
|
||||
// ===================
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { CreateTokenResponse } from '@/api'
|
||||
import {
|
||||
Button,
|
||||
CopyField,
|
||||
DataRow,
|
||||
Pill,
|
||||
SerialBar,
|
||||
SpecimenCard,
|
||||
SpecimenCardSection,
|
||||
} from '@/components'
|
||||
import { ArtifactDisplay } from './artifact'
|
||||
import { ARTIFACT_LABEL, PAGE_COPY } from './copy'
|
||||
import styles from './landing.module.scss'
|
||||
|
||||
type ResultViewProps = {
|
||||
data: CreateTokenResponse
|
||||
onAnother: () => void
|
||||
filenameFallback: string
|
||||
}
|
||||
|
||||
export function ResultView({
|
||||
data,
|
||||
onAnother,
|
||||
filenameFallback,
|
||||
}: ResultViewProps): React.ReactElement {
|
||||
const { token, artifact } = data
|
||||
const managePath = `/m/${token.manage_id}`
|
||||
return (
|
||||
<div className={styles.result}>
|
||||
<header className={styles.resultHead}>
|
||||
<span className={styles.resultMarker}>★</span>
|
||||
<h2 className={styles.resultHeadline}>{PAGE_COPY.RESULT_HEADLINE}</h2>
|
||||
<p className={styles.resultBody}>{PAGE_COPY.RESULT_BODY}</p>
|
||||
</header>
|
||||
|
||||
<SpecimenCard
|
||||
tag={`SPECIES // ${token.type.toUpperCase()}`}
|
||||
serial={<SerialBar value={token.id} prefix="SN" />}
|
||||
>
|
||||
<SpecimenCardSection label="DOSSIER">
|
||||
<DataRow label="memo">{token.memo || '—'}</DataRow>
|
||||
<DataRow label="filename" mono>
|
||||
{token.filename ?? '—'}
|
||||
</DataRow>
|
||||
<DataRow label="alert" emphasize>
|
||||
{token.alert_channel}
|
||||
</DataRow>
|
||||
<DataRow label="created" mono>
|
||||
{formatIso(token.created_at)}
|
||||
</DataRow>
|
||||
<DataRow label="status">
|
||||
<Pill tone={token.enabled ? 'signal' : 'alarm'} size="sm">
|
||||
{token.enabled ? 'armed' : 'disabled'}
|
||||
</Pill>
|
||||
</DataRow>
|
||||
</SpecimenCardSection>
|
||||
|
||||
<SpecimenCardSection label={ARTIFACT_LABEL[artifact.kind]}>
|
||||
<ArtifactDisplay
|
||||
artifact={artifact}
|
||||
filenameFallback={filenameFallback}
|
||||
/>
|
||||
</SpecimenCardSection>
|
||||
|
||||
<SpecimenCardSection label="ENDPOINTS">
|
||||
<CopyField label="MANAGE" value={token.manage_url} fullWidth />
|
||||
<CopyField label="TRIGGER" value={token.trigger_url} fullWidth />
|
||||
</SpecimenCardSection>
|
||||
</SpecimenCard>
|
||||
|
||||
<footer className={styles.resultFoot}>
|
||||
<Link to={managePath} className={styles.resultLink}>
|
||||
open dossier →
|
||||
</Link>
|
||||
<Button variant="ghost" onClick={onAnother}>
|
||||
{PAGE_COPY.ISSUE_ANOTHER_LABEL}
|
||||
</Button>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatIso(iso: string): string {
|
||||
try {
|
||||
const dt = new Date(iso)
|
||||
return dt
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d+Z$/, ' Z')
|
||||
} catch (_err) {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
// ===================
|
||||
// ©AngelaMos | 2026
|
||||
// type-picker.tsx
|
||||
// ===================
|
||||
|
||||
import type { TokenType, TypeDescriptor } from '@/api'
|
||||
import { Glyph } from '@/components'
|
||||
import { TOKEN_BLURB } from './copy'
|
||||
import styles from './landing.module.scss'
|
||||
|
||||
type TypePickerProps = {
|
||||
descriptors: TypeDescriptor[]
|
||||
value: TokenType | ''
|
||||
onChange: (next: TokenType) => void
|
||||
invalid?: boolean
|
||||
name?: string
|
||||
}
|
||||
|
||||
export function TypePicker({
|
||||
descriptors,
|
||||
value,
|
||||
onChange,
|
||||
invalid = false,
|
||||
name = 'token-type',
|
||||
}: TypePickerProps): React.ReactElement {
|
||||
return (
|
||||
<fieldset className={styles.typeGrid} data-invalid={invalid}>
|
||||
<legend className={styles.srOnly}>Token species</legend>
|
||||
{descriptors.map((d, idx) => (
|
||||
<TypeCard
|
||||
key={d.type}
|
||||
name={name}
|
||||
descriptor={d}
|
||||
index={idx + 1}
|
||||
selected={value === d.type}
|
||||
onSelect={() => onChange(d.type)}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
)
|
||||
}
|
||||
|
||||
type TypeCardProps = {
|
||||
name: string
|
||||
descriptor: TypeDescriptor
|
||||
index: number
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
function TypeCard({
|
||||
name,
|
||||
descriptor,
|
||||
index,
|
||||
selected,
|
||||
onSelect,
|
||||
}: TypeCardProps): React.ReactElement {
|
||||
const indexStr = String(index).padStart(2, '0')
|
||||
return (
|
||||
<label className={styles.typeCard} data-selected={selected}>
|
||||
<input
|
||||
className={styles.srOnly}
|
||||
type="radio"
|
||||
name={name}
|
||||
value={descriptor.type}
|
||||
checked={selected}
|
||||
onChange={onSelect}
|
||||
/>
|
||||
<span className={styles.typeIndex}>{indexStr}</span>
|
||||
<span className={styles.typeGlyph} aria-hidden="true">
|
||||
<Glyph type={descriptor.type} size={36} />
|
||||
</span>
|
||||
<span className={styles.typeName}>{descriptor.name}</span>
|
||||
<span className={styles.typeCode}>{descriptor.type}</span>
|
||||
<span className={styles.typeBlurb}>{TOKEN_BLURB[descriptor.type]}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue