Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/src/components/admin/users/invitations-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const EMPTY_COPY: Record<InvitesStatus, { title: string; body: string }> = {
},
expired: {
title: 'No expired invitations',
body: 'Pending invitations expire after 14 days. Expired ones show here so you can resend.',
body: 'Pending invitations expire after 30 days. Expired ones show here so you can resend.',
},
all: {
title: 'No invitations yet',
Expand Down
152 changes: 93 additions & 59 deletions apps/web/src/components/admin/users/invite-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,16 @@ interface InviteRowProps {
*
* The Revoke button uses an inline "confirm" two-step (avoids a dialog
* for a per-row destructive action). Copy-link mints a fresh magic-link
* URL valid for ~10 minutes so admins can hand it to invitees out of
* band when SMTP isn't reachable.
* URL (valid for the invite's lifetime) so admins can hand it to invitees
* out of band when SMTP isn't reachable.
*/
export function InviteRow({ invite, onRevoke, onResend, revoking, resending }: InviteRowProps) {
const [confirmRevoke, setConfirmRevoke] = useState(false)
const [copyState, setCopyState] = useState<'idle' | 'copying' | 'copied' | 'error'>('idle')
// When the clipboard write is blocked (denied permission, unfocused doc), the
// link the server already minted (and revoked the prior one for) must still
// reach the admin — surface it here for manual copy rather than losing it.
const [fallbackLink, setFallbackLink] = useState<string | null>(null)
const sentDate = invite.lastSentAt ?? invite.createdAt

const handleRevokeClick = () => {
Expand All @@ -82,73 +86,103 @@ export function InviteRow({ invite, onRevoke, onResend, revoking, resending }: I
const handleCopyLink = async () => {
if (copyState === 'copying') return
setCopyState('copying')
setFallbackLink(null)

let link: string
try {
const result = await getPortalInviteLinkFn({ data: { inviteId: invite.id } })
await navigator.clipboard.writeText(result.inviteLink)
setCopyState('copied')
setTimeout(() => setCopyState('idle'), 3000)
link = result.inviteLink
} catch {
setCopyState('error')
setTimeout(() => setCopyState('idle'), 3000)
return
}

// The link is already minted server-side (and the prior one revoked), so a
// clipboard failure must not lose it — fall back to showing it for manual copy.
try {
await navigator.clipboard.writeText(link)
setCopyState('copied')
setTimeout(() => setCopyState('idle'), 3000)
} catch {
setFallbackLink(link)
setCopyState('idle')
}
}

return (
<li className="flex items-center justify-between gap-3 rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{invite.email}</p>
<p className="mt-0.5 text-xs text-muted-foreground">Sent {formatInviteDate(sentDate)}</p>
<li className="rounded-md border border-border/50 bg-muted/20 px-3 py-2">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{invite.email}</p>
<p className="mt-0.5 text-xs text-muted-foreground">Sent {formatInviteDate(sentDate)}</p>
</div>
<InviteStatusBadge status={invite.status} />
{invite.status === 'pending' && (
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void handleCopyLink()}
disabled={copyState === 'copying' || revoking || resending}
className="h-7 px-2 text-xs"
title="Mint a fresh sign-in link and copy it to your clipboard"
>
{copyState === 'copying' && (
<ArrowPathIcon className="mr-1 h-3.5 w-3.5 animate-spin" />
)}
{copyState === 'copied'
? 'Link copied'
: copyState === 'error'
? 'Copy failed'
: 'Copy link'}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void onResend(invite.id)}
disabled={resending || revoking}
className="h-7 px-2 text-xs"
>
{resending ? <ArrowPathIcon className="h-3.5 w-3.5 animate-spin" /> : 'Resend'}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleRevokeClick}
disabled={resending || revoking}
className={cn(
'h-7 px-2 text-xs',
confirmRevoke
? 'border border-destructive/40 text-destructive hover:bg-destructive/10'
: 'text-muted-foreground hover:text-destructive'
)}
>
{revoking ? (
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin" />
) : confirmRevoke ? (
'Confirm revoke'
) : (
'Revoke'
)}
</Button>
</div>
)}
</div>
<InviteStatusBadge status={invite.status} />
{invite.status === 'pending' && (
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void handleCopyLink()}
disabled={copyState === 'copying' || revoking || resending}
className="h-7 px-2 text-xs"
title="Mint a fresh sign-in link and copy it to your clipboard"
>
{copyState === 'copying' && <ArrowPathIcon className="mr-1 h-3.5 w-3.5 animate-spin" />}
{copyState === 'copied'
? 'Copied — link valid 10 min'
: copyState === 'error'
? 'Copy failed'
: 'Copy link'}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void onResend(invite.id)}
disabled={resending || revoking}
className="h-7 px-2 text-xs"
>
{resending ? <ArrowPathIcon className="h-3.5 w-3.5 animate-spin" /> : 'Resend'}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleRevokeClick}
disabled={resending || revoking}
className={cn(
'h-7 px-2 text-xs',
confirmRevoke
? 'border border-destructive/40 text-destructive hover:bg-destructive/10'
: 'text-muted-foreground hover:text-destructive'
)}
>
{revoking ? (
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin" />
) : confirmRevoke ? (
'Confirm revoke'
) : (
'Revoke'
)}
</Button>
{fallbackLink && (
<div className="mt-2 space-y-1">
<p className="text-xs text-muted-foreground">
Couldn't copy automatically. Select and copy this link:
</p>
<input
readOnly
value={fallbackLink}
onFocus={(e) => e.currentTarget.select()}
className="w-full rounded border border-border/50 bg-background px-2 py-1 font-mono text-xs"
/>
</div>
)}
</li>
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/lib/server/auth/__tests__/email-signin.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

const hoisted = vi.hoisted(() => ({
mockMintMagicLinkUrl: vi.fn(async () => 'https://example.com/verify-magic-link?token=t'),
mockMintMagicLinkUrl: vi.fn(async () => ({
url: 'https://example.com/verify-magic-link?token=t',
token: 't',
})),
mockSendVerificationOTP: vi.fn(async () => undefined),
mockSendMagicLinkEmail: vi.fn(async () => undefined),
mockGetOTP: vi.fn(() => '123456'),
Expand Down
50 changes: 48 additions & 2 deletions apps/web/src/lib/server/auth/__tests__/magic-link-mint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,20 @@ vi.mock('../index', async () => {
}
})

const { mintMagicLinkUrl } = await import('../magic-link-mint')
const mockDeleteWhere = vi.fn().mockResolvedValue(undefined)
const mockDbDelete = vi.fn(() => ({ where: mockDeleteWhere }))
const mockEq = vi.fn((col: unknown, val: unknown) => ({ col, val }))
const mockVerificationTable = { identifier: 'verification.identifier' }

vi.mock('@/lib/server/db', () => ({
db: { delete: mockDbDelete },
verification: mockVerificationTable,
eq: mockEq,
inArray: vi.fn((col: unknown, vals: unknown) => ({ op: 'inArray', col, vals })),
}))

const { mintMagicLinkUrl, revokeMagicLinkToken, revokeMagicLinkTokens } =
await import('../magic-link-mint')

beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -76,12 +89,45 @@ describe('mintMagicLinkUrl', () => {
})

it('returns a /verify-magic-link URL with the token embedded', async () => {
const url = await mintMagicLinkUrl({
const { url, token } = await mintMagicLinkUrl({
email: 'a@b.com',
callbackPath: '/admin',
portalUrl: 'https://acme.test',
})
expect(url).toMatch(/^https:\/\/acme\.test\/verify-magic-link\?token=/)
expect(url).toContain('callbackURL=')
// The returned token is the verification identifier and the one in the URL.
expect(token).toBe(mockCreateVerificationValue.mock.calls[0][0].identifier)
expect(url).toContain(`token=${token}`)
})
})

describe('revokeMagicLinkToken', () => {
it('deletes the verification row whose identifier is the token', async () => {
await revokeMagicLinkToken('tok_abc')

expect(mockDbDelete).toHaveBeenCalledTimes(1)
expect(mockDbDelete).toHaveBeenCalledWith(mockVerificationTable)
expect(mockEq).toHaveBeenCalledWith(mockVerificationTable.identifier, 'tok_abc')
expect(mockDeleteWhere).toHaveBeenCalled()
})

it('is a no-op when the token is null (invite minted before tracking)', async () => {
await revokeMagicLinkToken(null)
expect(mockDbDelete).not.toHaveBeenCalled()
})
})

describe('revokeMagicLinkTokens', () => {
it('deletes the verification rows for the whole set', async () => {
await revokeMagicLinkTokens(['tok_a', 'tok_b'])
expect(mockDbDelete).toHaveBeenCalledTimes(1)
expect(mockDbDelete).toHaveBeenCalledWith(mockVerificationTable)
expect(mockDeleteWhere).toHaveBeenCalled()
})

it('is a no-op for an empty set', async () => {
await revokeMagicLinkTokens([])
expect(mockDbDelete).not.toHaveBeenCalled()
})
})
2 changes: 1 addition & 1 deletion apps/web/src/lib/server/auth/email-signin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export async function requestEmailSignin(opts: {
// back to /auth/login (the public signup/login screen).
const errorCallbackPath = opts.callbackURL.startsWith('/admin') ? '/admin/login' : '/auth/login'

const [signInUrl, , settings] = await Promise.all([
const [{ url: signInUrl }, , settings] = await Promise.all([
mintMagicLinkUrl({
email: opts.email,
callbackPath: opts.callbackURL,
Expand Down
32 changes: 30 additions & 2 deletions apps/web/src/lib/server/auth/magic-link-mint.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { generateRandomString } from 'better-auth/crypto'
import { db, verification, eq, inArray } from '@/lib/server/db'
import { getAuth } from './index'

interface MintOptions {
Expand Down Expand Up @@ -45,7 +46,7 @@ export function buildVerifyMagicLinkUrl(opts: {
* for internal token-mint. Token format mirrors BA's magic-link
* plugin so its `/magic-link/verify` endpoint reads our row.
*/
export async function mintMagicLinkUrl(opts: MintOptions): Promise<string> {
export async function mintMagicLinkUrl(opts: MintOptions): Promise<{ url: string; token: string }> {
const auth = await getAuth()
const token = generateRandomString(32, 'a-z', 'A-Z')
const expiresInSeconds = opts.expiresInSeconds ?? DEFAULT_EXPIRES_IN_SECONDS
Expand All @@ -59,10 +60,37 @@ export async function mintMagicLinkUrl(opts: MintOptions): Promise<string> {
expiresAt: new Date(Date.now() + expiresInSeconds * 1000),
})

return buildVerifyMagicLinkUrl({
const url = buildVerifyMagicLinkUrl({
origin: opts.portalUrl,
token,
callbackPath: opts.callbackPath,
errorCallbackPath: opts.errorCallbackPath,
})
// `token` is the verification-row identifier. Callers that need to be able
// to invalidate the link later (invitations) persist it; sign-in callers
// ignore it.
return { url, token }
}

/**
* Delete the verification row backing a previously-minted magic link, so the
* link can no longer be verified. Used by invitations when they're cancelled
* or re-issued. No-op when `token` is null/undefined (e.g. an invite minted
* before the token was tracked, or a path that never stored one).
*/
export async function revokeMagicLinkToken(token: string | null | undefined): Promise<void> {
if (!token) return
// `token` is the row's `identifier` — see `createVerificationValue` in
// mintMagicLinkUrl above, which stores the raw token as the identifier.
await db.delete(verification).where(eq(verification.identifier, token))
}

/**
* Bulk-revoke a set of magic-link tokens by deleting their verification rows.
* Used by invite cancellation to invalidate every link the invite ever minted.
* No-op on an empty set; already-consumed/expired tokens simply match no rows.
*/
export async function revokeMagicLinkTokens(tokens: string[]): Promise<void> {
if (tokens.length === 0) return
await db.delete(verification).where(inArray(verification.identifier, tokens))
}
Loading