From af11f5119c1aa299e2f56965b813a30136cd4009 Mon Sep 17 00:00:00 2001 From: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:44:51 +0200 Subject: [PATCH] v1.0.9 --- .env.development | 1 - .gitignore | 23 ++++++-------- CHANGELOG.md | 9 ++++++ README.md | 13 ++++++-- index.html | 1 + package.json | 2 +- src/lib/oauthClientId.test.ts | 56 +++++++++++++++++++++++++++++++++++ src/lib/oauthClientId.ts | 17 +++++++++++ src/services/api.ts | 3 +- src/services/auth/oauth.ts | 8 ++--- src/vite-env.d.ts | 1 - 11 files changed, 110 insertions(+), 24 deletions(-) create mode 100644 src/lib/oauthClientId.test.ts create mode 100644 src/lib/oauthClientId.ts diff --git a/.env.development b/.env.development index dcf8960..57d2868 100644 --- a/.env.development +++ b/.env.development @@ -1,5 +1,4 @@ VITE_API_BASE_URL=http://localhost:8080 -VITE_OAUTH_CLIENT_ID=stalwart-webui #VITE_ACCESS_TOKEN=OPEN_SESAME VITE_OAUTH_SCOPES= diff --git a/.gitignore b/.gitignore index 66626f3..6940c20 100644 --- a/.gitignore +++ b/.gitignore @@ -12,19 +12,14 @@ dist dist-ssr *.local -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? .ignore scripts/ -*.md -!README.md -!CHANGELOG.md -/SPEC-* + +.* +!.gitignore +!.prettierrc +!.env.development +!.github/ +!.vscode/ +.vscode/* +!.vscode/extensions.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ae4ad..3cd7229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). +## [1.0.9] - 2026-08-24 + +### Added +- Server configurable OAuth client ID. + +### Changed + +### Fixed + ## [1.0.8] - 2026-07-31 ### Added diff --git a/README.md b/README.md index 0a77d6e..de00963 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,6 @@ Configuration is done through Vite environment variables. Copy or edit `.env.dev ``` VITE_API_BASE_URL=http://localhost:443 -VITE_OAUTH_CLIENT_ID=stalwart-webui VITE_ACCESS_TOKEN= VITE_OAUTH_SCOPES= ``` @@ -79,10 +78,20 @@ VITE_OAUTH_SCOPES= | Variable | Description | |---|---| | `VITE_API_BASE_URL` | URL of the Stalwart server. Used for all API requests during development. In production builds (when empty or unset) requests are relative to the current origin. | -| `VITE_OAUTH_CLIENT_ID` | OAuth 2.0 client ID. Defaults to `stalwart-webui`. | | `VITE_ACCESS_TOKEN` | When set, skips the OAuth flow entirely and uses this token for all requests. Useful for local development and testing. | | `VITE_OAUTH_SCOPES` | Optional OAuth scopes. Omitted from the authorization request when empty. | +### OAuth client ID + +The OAuth 2.0 client ID is not a build-time setting. It is read at runtime from a meta tag in `index.html`: + +```html + +``` + +The server rewrites the `content` attribute when it serves the page, so a single build works for any deployment. When no +client ID is configured the attribute is left empty and the panel falls back to `stalwart-webui`. + ### Bypassing OAuth for development Set `VITE_ACCESS_TOKEN` to a valid bearer token to skip the login page and go straight to the admin panel. You can obtain a token from the Stalwart server's token endpoint or use an API key: diff --git a/index.html b/index.html index 12bf8c6..c5a0dfd 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ + Portal diff --git a/package.json b/package.json index 77f2371..1a2377f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stalwart-webui", "private": true, - "version": "1.0.8", + "version": "1.0.9", "description": "Stalwart WebUI", "type": "module", "scripts": { diff --git a/src/lib/oauthClientId.test.ts b/src/lib/oauthClientId.test.ts new file mode 100644 index 0000000..4c0db44 --- /dev/null +++ b/src/lib/oauthClientId.test.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; + +const originalHead = document.head.innerHTML; + +async function loadWithMeta(meta: string) { + document.head.innerHTML = meta; + vi.resetModules(); + const { getOAuthClientId } = await import('./oauthClientId'); + return getOAuthClientId; +} + +afterEach(() => { + document.head.innerHTML = originalHead; + vi.resetModules(); +}); + +describe('getOAuthClientId', () => { + it('prefers the client id injected by the server', async () => { + const getOAuthClientId = await loadWithMeta(''); + expect(getOAuthClientId()).toBe('pocket-id-client'); + }); + + it('trims surrounding whitespace from the injected client id', async () => { + const getOAuthClientId = await loadWithMeta(''); + expect(getOAuthClientId()).toBe('pocket-id-client'); + }); + + it('falls back to the built-in default when the placeholder is empty', async () => { + const getOAuthClientId = await loadWithMeta(''); + expect(getOAuthClientId()).toBe('stalwart-webui'); + }); + + it('falls back to the built-in default when the placeholder is only whitespace', async () => { + const getOAuthClientId = await loadWithMeta(''); + expect(getOAuthClientId()).toBe('stalwart-webui'); + }); + + it('falls back to the built-in default when the placeholder is absent', async () => { + const getOAuthClientId = await loadWithMeta(''); + expect(getOAuthClientId()).toBe('stalwart-webui'); + }); + + it('reads the document only once', async () => { + const getOAuthClientId = await loadWithMeta(''); + expect(getOAuthClientId()).toBe('pocket-id-client'); + + document.head.innerHTML = ''; + expect(getOAuthClientId()).toBe('pocket-id-client'); + }); +}); diff --git a/src/lib/oauthClientId.ts b/src/lib/oauthClientId.ts new file mode 100644 index 0000000..ec125be --- /dev/null +++ b/src/lib/oauthClientId.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +const DEFAULT_CLIENT_ID = 'stalwart-webui'; + +let cached: string | undefined; + +export function getOAuthClientId(): string { + if (cached !== undefined) return cached; + + const injected = document.querySelector('meta[name="oauth-client-id"]')?.getAttribute('content')?.trim(); + cached = injected ? injected : DEFAULT_CLIENT_ID; + return cached; +} diff --git a/src/services/api.ts b/src/services/api.ts index b8d6745..8a71eb1 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -6,6 +6,7 @@ import { useAuthStore } from '../stores/authStore'; import { getBasePath } from '@/lib/basePath'; +import { getOAuthClientId } from '@/lib/oauthClientId'; export function getApiBaseUrl(): string { const envUrl = import.meta.env.VITE_API_BASE_URL as string | undefined; @@ -45,7 +46,7 @@ export async function refreshAccessToken(): Promise { throw new Error('No refresh token or token endpoint available'); } - const clientId = (import.meta.env.VITE_OAUTH_CLIENT_ID as string) || 'stalwart-webui'; + const clientId = getOAuthClientId(); try { const response = await fetch(tokenEndpoint, { diff --git a/src/services/auth/oauth.ts b/src/services/auth/oauth.ts index 58e18ee..5d540d1 100644 --- a/src/services/auth/oauth.ts +++ b/src/services/auth/oauth.ts @@ -6,9 +6,9 @@ import { getApiBaseUrl } from '@/services/api'; import { getBasePath } from '@/lib/basePath'; +import { getOAuthClientId } from '@/lib/oauthClientId'; import i18n from '@/i18n'; -const CLIENT_ID = (import.meta.env.VITE_OAUTH_CLIENT_ID as string) || 'stalwart-webui'; const SCOPES = import.meta.env.VITE_OAUTH_SCOPES as string | undefined; const SESSION_PREFIX = 'stalwart-oauth-'; @@ -97,7 +97,7 @@ export async function exchangeCode( grant_type: 'authorization_code', code, code_verifier: codeVerifier, - client_id: CLIENT_ID, + client_id: getOAuthClientId(), redirect_uri: redirectUri, }); @@ -153,7 +153,7 @@ export async function startAuthFlow(username: string, returnUrl?: string | null) const params = new URLSearchParams({ response_type: 'code', - client_id: CLIENT_ID, + client_id: getOAuthClientId(), redirect_uri: getRedirectUri(), code_challenge: codeChallenge, code_challenge_method: codeChallengeMethod, @@ -206,7 +206,7 @@ export function getPostLogoutRedirectUri(): string { export function buildEndSessionUrl(endSessionEndpoint: string, postLogoutRedirectUri: string): string { const params = new URLSearchParams({ - client_id: CLIENT_ID, + client_id: getOAuthClientId(), post_logout_redirect_uri: postLogoutRedirectUri, }); const sep = endSessionEndpoint.includes('?') ? '&' : '?'; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 9b7f6ad..37a9a68 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -8,7 +8,6 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL: string; - readonly VITE_OAUTH_CLIENT_ID: string; readonly VITE_ACCESS_TOKEN: string; readonly VITE_OAUTH_SCOPES: string; readonly VITE_DEBUG_JMAP?: string;