feat: add oauth account management url (#30873)

This commit is contained in:
Sacha Brouté
2026-08-20 21:31:34 +00:00
committed by GitHub
parent 24532c4d82
commit c7f6197e75
13 changed files with 46 additions and 1 deletions
@@ -77,6 +77,7 @@ const setupOAuth = async (token: string, dto: Partial<AdminConfigOAuthDto>) => {
...defaults.oauth, ...defaults.oauth,
buttonText: 'Login with Immich', buttonText: 'Login with Immich',
issuerUrl: `${authServer.internal}/.well-known/openid-configuration`, issuerUrl: `${authServer.internal}/.well-known/openid-configuration`,
accountManagementUrl: authServer.internal,
allowInsecureRequests: true, allowInsecureRequests: true,
...dto, ...dto,
}; };
@@ -121,6 +121,7 @@ describe('/server', () => {
expect(body).toEqual({ expect(body).toEqual({
loginPageMessage: '', loginPageMessage: '',
oauthButtonText: 'Login with OAuth', oauthButtonText: 'Login with OAuth',
oauthAccountManagementUrl: '',
trashDays: 30, trashDays: 30,
userDeleteDelay: 7, userDeleteDelay: 7,
isInitialized: true, isInitialized: true,
+3
View File
@@ -294,6 +294,8 @@
"notification_enable_email_notifications": "Enable email notifications", "notification_enable_email_notifications": "Enable email notifications",
"notification_settings": "Notification Settings", "notification_settings": "Notification Settings",
"notification_settings_description": "Manage notification settings, including email", "notification_settings_description": "Manage notification settings, including email",
"oauth_account_management_url": "Account Management URL",
"oauth_account_management_url_description": "Location in the external identity provider where a user can manage their settings or profile.",
"oauth_allow_insecure_requests": "Allow insecure requests", "oauth_allow_insecure_requests": "Allow insecure requests",
"oauth_allow_insecure_requests_description": "WARNING: This disables TLS certificate validation for OAuth requests and may expose you to MITM attacks.", "oauth_allow_insecure_requests_description": "WARNING: This disables TLS certificate validation for OAuth requests and may expose you to MITM attacks.",
"oauth_auto_launch": "Auto launch", "oauth_auto_launch": "Auto launch",
@@ -1379,6 +1381,7 @@
"manage_media_access_settings": "Open settings", "manage_media_access_settings": "Open settings",
"manage_media_access_subtitle": "Allow the Immich app to manage and move media files.", "manage_media_access_subtitle": "Allow the Immich app to manage and move media files.",
"manage_media_access_title": "Media Management Access", "manage_media_access_title": "Media Management Access",
"manage_oauth_account": "Manage OAuth Account",
"manage_sharing_with_other_users": "Manage sharing with other users", "manage_sharing_with_other_users": "Manage sharing with other users",
"manage_sharing_with_partners": "Manage sharing with partners", "manage_sharing_with_partners": "Manage sharing with partners",
"manage_the_app_settings": "Manage the app settings", "manage_the_app_settings": "Manage the app settings",
+10
View File
@@ -18232,6 +18232,11 @@
}, },
"AdminConfigOAuthDto": { "AdminConfigOAuthDto": {
"properties": { "properties": {
"accountManagementUrl": {
"default": "",
"description": "Account management URL",
"type": "string"
},
"allowInsecureRequests": { "allowInsecureRequests": {
"description": "Allow insecure requests", "description": "Allow insecure requests",
"type": "boolean" "type": "boolean"
@@ -24593,6 +24598,11 @@
"minimum": -9007199254740991, "minimum": -9007199254740991,
"type": "integer" "type": "integer"
}, },
"oauthAccountManagementUrl": {
"default": "",
"description": "OAuth account management URL",
"type": "string"
},
"oauthButtonText": { "oauthButtonText": {
"description": "OAuth button text", "description": "OAuth button text",
"type": "string" "type": "string"
+4
View File
@@ -313,6 +313,8 @@ export type AdminConfigNotificationsDto = {
smtp: AdminConfigSmtpDto; smtp: AdminConfigSmtpDto;
}; };
export type AdminConfigOAuthDto = { export type AdminConfigOAuthDto = {
/** Account management URL */
accountManagementUrl?: string;
/** Allow insecure requests */ /** Allow insecure requests */
allowInsecureRequests: boolean; allowInsecureRequests: boolean;
/** Auto launch */ /** Auto launch */
@@ -2521,6 +2523,8 @@ export type ServerConfigDto = {
mapLightStyleUrl: string; mapLightStyleUrl: string;
/** People min faces server default */ /** People min faces server default */
minFaces: number; minFaces: number;
/** OAuth account management URL */
oauthAccountManagementUrl?: string;
/** OAuth button text */ /** OAuth button text */
oauthButtonText: string; oauthButtonText: string;
/** Whether public user registration is enabled */ /** Whether public user registration is enabled */
+5
View File
@@ -291,6 +291,10 @@ const AdminConfigSchemaWithVisibility = z
defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'), defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'),
enabled: configBool.describe('Enabled').meta({ visibility: Public }), enabled: configBool.describe('Enabled').meta({ visibility: Public }),
issuerUrl: emptyOrUrl('Issuer URL must be an empty string or a valid URL').describe('Issuer URL'), issuerUrl: emptyOrUrl('Issuer URL must be an empty string or a valid URL').describe('Issuer URL'),
accountManagementUrl: emptyOrUrl('Account management URL must be an empty string or a valid URL')
.describe('Account management URL')
.optional()
.default(''),
scope: z.string().describe('Scope'), scope: z.string().describe('Scope'),
prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'), prompt: z.string().describe('OAuth prompt parameter (e.g. select_account, login, consent)'),
endSessionEndpoint: emptyOrUrl('endSessionEndpoint must be an empty string or a valid URL').describe( endSessionEndpoint: emptyOrUrl('endSessionEndpoint must be an empty string or a valid URL').describe(
@@ -668,6 +672,7 @@ export const defaults = Object.freeze<SystemConfig>({
defaultStorageQuota: null, defaultStorageQuota: null,
enabled: false, enabled: false,
issuerUrl: '', issuerUrl: '',
accountManagementUrl: '',
endSessionEndpoint: '', endSessionEndpoint: '',
mobileOverrideEnabled: false, mobileOverrideEnabled: false,
mobileRedirectUri: '', mobileRedirectUri: '',
+1
View File
@@ -114,6 +114,7 @@ const ServerMediaTypesResponseSchema = z
const ServerConfigSchema = z const ServerConfigSchema = z
.object({ .object({
oauthButtonText: z.string().describe('OAuth button text'), oauthButtonText: z.string().describe('OAuth button text'),
oauthAccountManagementUrl: z.string().describe('OAuth account management URL').optional().default(''),
loginPageMessage: z.string().describe('Login page message'), loginPageMessage: z.string().describe('Login page message'),
trashDays: z.int().describe('Number of days before trashed assets are permanently deleted'), trashDays: z.int().describe('Number of days before trashed assets are permanently deleted'),
userDeleteDelay: z.int().describe('Delay in days before deleted users are permanently removed'), userDeleteDelay: z.int().describe('Delay in days before deleted users are permanently removed'),
@@ -22,6 +22,7 @@ export type OAuthConfig = {
clientId: string; clientId: string;
clientSecret?: string; clientSecret?: string;
issuerUrl: string; issuerUrl: string;
accountManagementUrl: string;
endSessionEndpoint: string; endSessionEndpoint: string;
mobileOverrideEnabled: boolean; mobileOverrideEnabled: boolean;
mobileRedirectUri: string; mobileRedirectUri: string;
@@ -160,6 +160,7 @@ describe(ServerService.name, () => {
await expect(sut.getSystemConfig()).resolves.toEqual({ await expect(sut.getSystemConfig()).resolves.toEqual({
loginPageMessage: '', loginPageMessage: '',
oauthButtonText: 'Login with OAuth', oauthButtonText: 'Login with OAuth',
oauthAccountManagementUrl: '',
trashDays: 30, trashDays: 30,
userDeleteDelay: 7, userDeleteDelay: 7,
isInitialized: false, isInitialized: false,
+1
View File
@@ -120,6 +120,7 @@ export class ServerService extends BaseService {
trashDays: config.trash.days, trashDays: config.trash.days,
userDeleteDelay: config.user.deleteDelay, userDeleteDelay: config.user.deleteDelay,
oauthButtonText: config.oauth.buttonText, oauthButtonText: config.oauth.buttonText,
oauthAccountManagementUrl: config.oauth.accountManagementUrl,
isInitialized, isInitialized,
isOnboarded: onboarding?.isOnboarded || false, isOnboarded: onboarding?.isOnboarded || false,
externalDomain: config.server.externalDomain, externalDomain: config.server.externalDomain,
@@ -154,6 +154,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
enabled: true, enabled: true,
}, },
oauth: { oauth: {
accountManagementUrl: '',
autoLaunch: true, autoLaunch: true,
autoRegister: true, autoRegister: true,
buttonText: 'Login with OAuth', buttonText: 'Login with OAuth',
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { oauth } from '$lib/utils'; import { oauth } from '$lib/utils';
@@ -41,13 +42,18 @@
<section class="my-4"> <section class="my-4">
<div in:fade={{ duration: 500 }}> <div in:fade={{ duration: 500 }}>
<div class="flex justify-end sm:ms-8"> <div class="flex justify-end gap-3 sm:ms-8">
{#if loading} {#if loading}
<div class="flex place-content-center place-items-center"> <div class="flex place-content-center place-items-center">
<LoadingSpinner /> <LoadingSpinner />
</div> </div>
{:else if featureFlagsManager.value.oauth} {:else if featureFlagsManager.value.oauth}
{#if authManager.user.oauthId} {#if authManager.user.oauthId}
{#if serverConfigManager.value.oauthAccountManagementUrl}
<Button shape="round" size="small" href={serverConfigManager.value.oauthAccountManagementUrl}
>{$t('manage_oauth_account')}</Button
>
{/if}
<Button shape="round" size="small" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button> <Button shape="round" size="small" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
{:else} {:else}
<Button shape="round" size="small" onclick={() => oauth.authorize(location)}>{$t('link_to_oauth')}</Button> <Button shape="round" size="small" onclick={() => oauth.authorize(location)}>{$t('link_to_oauth')}</Button>
@@ -251,6 +251,16 @@
isEdited={configToEdit.oauth.buttonText !== config.oauth.buttonText} isEdited={configToEdit.oauth.buttonText !== config.oauth.buttonText}
/> />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label={$t('admin.oauth_account_management_url')}
description={$t('admin.oauth_account_management_url_description')}
bind:value={configToEdit.oauth.accountManagementUrl}
required={false}
disabled={disabled || !configToEdit.oauth.enabled}
isEdited={configToEdit.oauth.accountManagementUrl !== config.oauth.accountManagementUrl}
/>
<SettingSwitch <SettingSwitch
title={$t('admin.oauth_auto_register')} title={$t('admin.oauth_auto_register')}
subtitle={$t('admin.oauth_auto_register_description')} subtitle={$t('admin.oauth_auto_register_description')}