Skip to content

Commit 903c106

Browse files
committed
chore: final nits
1 parent 3830b29 commit 903c106

3 files changed

Lines changed: 51 additions & 9 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client';
2+
3+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
4+
import { useEffect, useRef } from 'react';
5+
import { pushErrorBanner } from '@/api/errorBanner';
6+
7+
const MESSAGES = {
8+
expired: 'That /dashboard link expired (links only last 2 minutes) - run /dashboard again for a new one.',
9+
used: 'That /dashboard link has already been used - run /dashboard again for a new one.',
10+
forbidden: "You don't have permission to manage that server.",
11+
unavailable: 'Something went wrong opening that /dashboard link. Please try again in a moment.',
12+
} as const;
13+
14+
/**
15+
* Surfaces `GET /v3/auth/dashboard`'s (`services/api/src/routes/auth/dashboardLink.ts`) failure bounces --
16+
* it 302s here with `?dashboard_link_error=...` rather than a JSON body, since the exchange happens outside
17+
* any page the user is looking at. Renders nothing itself; pushes a dismissible toast (`ErrorBanner`, mounted
18+
* globally in `Providers`) and strips the query param so a refresh doesn't re-fire it.
19+
*/
20+
export function DashboardLinkErrorNotice() {
21+
const searchParams = useSearchParams();
22+
const router = useRouter();
23+
const pathname = usePathname();
24+
const handled = useRef(false);
25+
26+
const error = searchParams.get('dashboard_link_error');
27+
28+
useEffect(() => {
29+
if (!error || handled.current) {
30+
return;
31+
}
32+
33+
handled.current = true;
34+
pushErrorBanner(error in MESSAGES ? MESSAGES[error as keyof typeof MESSAGES] : MESSAGES.unavailable);
35+
router.replace(pathname);
36+
}, [error, pathname, router]);
37+
38+
return null;
39+
}

apps/website/src/app/dashboard/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Metadata } from 'next';
2+
import { DashboardLinkErrorNotice } from './_components/DashboardLinkErrorNotice';
23
import { GuildList } from './_components/GuildList';
34
import { RefreshGuildsButton } from './_components/RefreshGuildsButton';
45
import { Heading } from '@/components/common/Heading';
@@ -11,6 +12,7 @@ export const metadata: Metadata = {
1112
export default function DashboardPage() {
1213
return (
1314
<>
15+
<DashboardLinkErrorNotice />
1416
<div className="flex flex-col [&>*:not(:first-of-type)]:mt-8 [&>*]:first-of-type:mb-4">
1517
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
1618
<Heading subtitle="Select or add a community to manage." title="Configure bots" />

docs/roadmap/01-architecture.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -191,15 +191,16 @@ command — identical on every bot, registered automatically by `createBotClient
191191
to one guild, rather than a token authorizing one specific action. Past the exchange there is a single auth code
192192
path: a scoped session flows through exactly the same `isAuthed` logic as a real OAuth session.
193193

194-
- **`/dashboard open`**: mints a short-lived (~2 min), single-use `DashboardLinkTokenData` JWT (`{ kind:
195-
'dashboard-link', sub, guildId, jti, iat }`, `packages/private/backend-core/src/lib/dashboardSession.ts`) and
196-
replies ephemerally with a spoilered link to `GET {API_URL}/v3/auth/dashboard?token=...` — the link points at the
197-
API, not the dashboard, so the token is only ever in the URL for that one initial request; the exchange consumes
198-
it and 302s to a clean `/dashboard/:guildId` URL with nothing in it, unlike the token itself ending up in the
199-
dashboard's own address bar for the whole visit the way the old grant tokens did. The reply text warns that the
200-
link expires in 2 minutes and is single-use, the session it grants lasts 30 minutes with full guild-manager
201-
access to that one guild, and — if the user is already logged in normally in that browser — opening it will
202-
replace their session. `/dashboard revoke` ends every live scoped session for the caller in that guild
194+
- **`/dashboard open`**: mints a short-lived (~2 min), best-effort-single-use (see the claim-durability note below)
195+
`DashboardLinkTokenData` JWT (`{ kind: 'dashboard-link', sub, guildId, jti, iat }`,
196+
`packages/private/backend-core/src/lib/dashboardSession.ts`) and replies ephemerally with a spoilered link to
197+
`GET {API_URL}/v3/auth/dashboard?token=...` — the link points at the API, not the dashboard, so the token is
198+
only ever in the URL for that one initial request; the exchange consumes it and 302s to a clean
199+
`/dashboard/:guildId` URL with nothing in it, unlike the token itself ending up in the dashboard's own address
200+
bar for the whole visit the way the old grant tokens did. The reply text warns that the link expires in 2
201+
minutes and is meant to be used once, the session it grants lasts 30 minutes with full guild-manager access to
202+
that one guild, and — if the user is already logged in normally in that browser — opening it will replace their
203+
session. `/dashboard revoke` ends every live scoped session for the caller in that guild
203204
(`revokeDashboardSessionsFor`).
204205
- **Exchange** (`services/api/src/routes/auth/dashboardLink.ts`, no `isAuthed` — there's no session yet):
205206
verifies + atomically claims the link token (`claimDashboardLinkToken`, `SET ... NX`, same one-time-use pattern

0 commit comments

Comments
 (0)