feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079)

* feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos

- Rework the mobile dashboard: cover hero, separate boarding-pass card,
  trimmed atlas (trips + days only), stacked widgets
- New floating bottom tab bar with a centred context-aware + button
  (new trip / place / journey / entry depending on the page)
- Move profile + notifications into a small top strip on the dashboard
- Desktop: glassmorphic tiles (light + dark), neutral dark palette,
  plain-text countdown module, real place photos in the boarding pass

* i18n(dashboard): translate new dashboard keys across all locales

Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy
dialog, aria labels, countdown) that were left as English placeholders,
plus the new startsIn/aria keys, for all 19 languages.

* feat(oidc): send PKCE (S256) in the OIDC login flow

The OIDC client now generates a code_verifier per login, sends the
S256 code_challenge on the authorize request and the code_verifier on
the token exchange. Works whether the provider has PKCE optional or
required (fixes login against providers that require PKCE, e.g. Pocket ID).
This commit is contained in:
Maurice
2026-05-27 23:19:03 +02:00
committed by GitHub
parent 0d2657ee37
commit 6d2dd37414
34 changed files with 1692 additions and 1296 deletions
+23 -11
View File
@@ -57,7 +57,7 @@ const DISCOVERY_TTL = 60 * 60 * 1000; // 1 hour
// State management pending OIDC states
// ---------------------------------------------------------------------------
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string }>();
const pendingStates = new Map<string, { createdAt: number; redirectUri: string; inviteToken?: string; codeVerifier: string }>();
setInterval(() => {
const now = Date.now();
@@ -66,10 +66,19 @@ setInterval(() => {
}
}, STATE_CLEANUP);
export function createState(redirectUri: string, inviteToken?: string): string {
function base64url(buf: Buffer): string {
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// Creates the login state and a matching PKCE pair. The verifier stays server
// side (in pendingStates); the S256 challenge goes to the provider so PKCE-
// required setups (e.g. Pocket ID with PKCE = required) work.
export function createState(redirectUri: string, inviteToken?: string): { state: string; codeChallenge: string } {
const state = crypto.randomBytes(32).toString('hex');
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken });
return state;
const codeVerifier = base64url(crypto.randomBytes(32));
const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());
pendingStates.set(state, { createdAt: Date.now(), redirectUri, inviteToken, codeVerifier });
return { state, codeChallenge };
}
export function consumeState(state: string) {
@@ -204,17 +213,20 @@ export async function exchangeCodeForToken(
redirectUri: string,
clientId: string,
clientSecret: string,
codeVerifier?: string,
): Promise<OidcTokenResponse & { _ok: boolean; _status: number }> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
});
if (codeVerifier) body.set('code_verifier', codeVerifier);
const tokenRes = await fetch(doc.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
}),
body,
});
const tokenData = (await tokenRes.json()) as OidcTokenResponse;
return { ...tokenData, _ok: tokenRes.ok, _status: tokenRes.status };