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
+7 -7
View File
@@ -160,7 +160,7 @@ describe('GET /api/auth/oidc/callback', () => {
});
// Create a valid state token
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`);
@@ -178,7 +178,7 @@ describe('GET /api/auth/oidc/callback', () => {
name: 'New User',
});
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=code999&state=${state}`);
@@ -215,7 +215,7 @@ describe('GET /api/auth/oidc/callback', () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ _ok: false, _status: 400 });
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=badcode&state=${state}`);
@@ -227,7 +227,7 @@ describe('GET /api/auth/oidc/callback', () => {
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 }); // no id_token
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
@@ -240,7 +240,7 @@ describe('GET /api/auth/oidc/callback', () => {
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'bad.id.token', _ok: true, _status: 200 });
mockVerifyIdToken.mockResolvedValueOnce({ ok: false, error: 'signature_or_claim_mismatch: invalid signature' });
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
@@ -258,7 +258,7 @@ describe('GET /api/auth/oidc/callback', () => {
name: 'Alice',
});
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
@@ -281,7 +281,7 @@ describe('GET /api/auth/oidc/callback', () => {
name: 'Blocked',
});
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
@@ -83,17 +83,20 @@ afterAll(() => {
// ── createState / consumeState ────────────────────────────────────────────────
describe('createState / consumeState', () => {
it('OIDC-SVC-001: createState returns a hex token', () => {
const state = createState('https://example.com/callback');
it('OIDC-SVC-001: createState returns a hex token + PKCE S256 challenge', () => {
const { state, codeChallenge } = createState('https://example.com/callback');
expect(state).toMatch(/^[0-9a-f]{64}$/);
expect(codeChallenge).toMatch(/^[A-Za-z0-9_-]{43}$/); // base64url SHA-256, no padding
});
it('OIDC-SVC-002: consumeState returns stored data and deletes state', () => {
const state = createState('https://example.com/callback', 'invite-abc');
it('OIDC-SVC-002: consumeState returns stored data (incl. verifier) and deletes state', () => {
const { state } = createState('https://example.com/callback', 'invite-abc');
const data = consumeState(state);
expect(data).not.toBeNull();
expect(data!.redirectUri).toBe('https://example.com/callback');
expect(data!.inviteToken).toBe('invite-abc');
expect(typeof data!.codeVerifier).toBe('string');
expect(data!.codeVerifier.length).toBeGreaterThan(20);
// State is consumed — second call returns null
expect(consumeState(state)).toBeNull();
});
@@ -103,8 +106,8 @@ describe('createState / consumeState', () => {
});
it('OIDC-SVC-004: two different states do not conflict', () => {
const s1 = createState('http://a.example.com');
const s2 = createState('http://b.example.com');
const { state: s1 } = createState('http://a.example.com');
const { state: s2 } = createState('http://b.example.com');
expect(s1).not.toBe(s2);
expect(consumeState(s1)!.redirectUri).toBe('http://a.example.com');
expect(consumeState(s2)!.redirectUri).toBe('http://b.example.com');