Files
zeekay 2e6b0f5b3c auth: email first, the link by default; /beta applies before the account
The first screen asks for the address alone. Every address is sent its link and code at once; a known account's password is a press away, a new one's is optional and typed twice. The emailed link lands on /auth/callback and hands its code to the waiting tab, signs in the browser that asked, or shows the code elsewhere. Apple leads the providers when IAM offers it. /beta asks its questions signed out and makes the account on /signup at the end, then goes on to the plan.
2026-10-21 22:02:11 -07:00

73 lines
2.8 KiB
TypeScript

/**
* The sign-in link IAM emails beside the code (hanzoai/iam otp `IssueLink`): the
* application's own callback with the address and the code in the fragment,
* `/auth/callback#email=…&code=…`. The fragment never reaches a server, a log or
* a Referer.
*
* Opened in the browser that asked for it, the link hands its code to the tab
* still waiting on it (`CHANNEL`), and that tab finishes the sign-in: it holds
* what the code alone cannot, a new account's password and terms, and another
* app's request. With no tab waiting, the browser that asked (`MARK`) signs in
* where the link opened. Anywhere else the link shows its code, to type where
* the sign-in began. Nothing here spends the code: a mail scanner that opens the
* link signs nobody in.
*/
/** The tabs of one browser, waiting on a link and opening one. */
export const CHANNEL = 'signin.link'
/** The address this browser asked for a link for, and when (localStorage). */
const MARK = 'signin.link'
/** How long a mark stands: IAM's code lasts ten minutes (otp `TTL`). */
const TTL = 10 * 60 * 1000
/** How long an opened link waits for the tab that asked, in ms. */
export const WAIT = 1500
/** What a link carries, and what it hands the tab that asked. */
export type Handed = { email: string; code: string }
/** The link's address and code, from its fragment; null for any other address. */
export function read(hash: string): Handed | null {
const q = new URLSearchParams(hash.replace(/^#/, ''))
const email = q.get('email')?.trim()
const code = q.get('code')?.trim()
return email && code ? { email, code } : null
}
/** Whether a message on the channel is a link for this address. */
export function names(data: unknown, email: string): data is Handed {
if (!data || typeof data !== 'object') return false
const m = data as Partial<Handed>
return typeof m.code === 'string' && typeof m.email === 'string' && m.email.toLowerCase() === email.trim().toLowerCase()
}
/** This browser asked for a link for `email`, now. */
export function mark(email: string, now = Date.now()): void {
try {
localStorage.setItem(MARK, JSON.stringify({ email: email.trim().toLowerCase(), at: now }))
} catch {
/* no store: a link opened here shows its code */
}
}
/** Whether this browser asked for a link for `email` within the code's life. */
export function marked(email: string, now = Date.now()): boolean {
try {
const m = JSON.parse(localStorage.getItem(MARK) ?? 'null') as { email?: string; at?: number } | null
return !!m && m.email === email.trim().toLowerCase() && typeof m.at === 'number' && now - m.at < TTL
} catch {
return false
}
}
/** The mark spent: the link it stood for has signed in. */
export function unmark(): void {
try {
localStorage.removeItem(MARK)
} catch {
/* nothing kept */
}
}