diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index 34c4c963..d87716b0 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -5,6 +5,7 @@ import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" type OidcClientMetaDataDto struct { ID string `json:"id"` Name string `json:"name"` + Description string `json:"description"` HasLogo bool `json:"hasLogo"` HasDarkLogo bool `json:"hasDarkLogo"` LaunchURL *string `json:"launchURL"` @@ -36,6 +37,7 @@ type OidcClientWithAllowedGroupsCountDto struct { type OidcClientUpdateDto struct { Name string `json:"name" binding:"required,max=50" unorm:"nfc"` + Description string `json:"description" binding:"omitempty,max=150" unorm:"nfc"` CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"` LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"` IsPublic bool `json:"isPublic"` diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index c0e4ad97..5392091c 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -23,6 +23,7 @@ type OidcClient struct { Base Name string `sortable:"true"` + Description string Secret string CallbackURLs UrlList LogoutCallbackURLs UrlList diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index ae8401f4..908d644c 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -187,6 +187,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { ID: "3654a746-35d4-4321-ac61-0bdcff2b4055", }, Name: "Nextcloud", + Description: "This is an example description for Nextcloud", LaunchURL: new("https://nextcloud.local"), Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY CallbackURLs: model.UrlList{"http://nextcloud.localhost/auth/callback"}, diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index d2c95a44..d667e107 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -213,6 +213,7 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) { // Base fields client.Name = input.Name + client.Description = input.Description client.CallbackURLs = input.CallbackURLs client.LogoutCallbackURLs = input.LogoutCallbackURLs client.IsPublic = input.IsPublic @@ -594,16 +595,16 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri // If user has no groups, only return clients with no allowed user groups if len(userGroupIDs) == 0 { query = query.Where(`NOT EXISTS ( - SELECT 1 FROM oidc_clients_allowed_user_groups + SELECT 1 FROM oidc_clients_allowed_user_groups WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id)`) } else { query = query.Where(` NOT EXISTS ( - SELECT 1 FROM oidc_clients_allowed_user_groups + SELECT 1 FROM oidc_clients_allowed_user_groups WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id ) OR EXISTS ( - SELECT 1 FROM oidc_clients_allowed_user_groups - WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id + SELECT 1 FROM oidc_clients_allowed_user_groups + WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, userGroupIDs) } @@ -632,6 +633,7 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri OidcClientMetaDataDto: dto.OidcClientMetaDataDto{ ID: client.ID, Name: client.Name, + Description: client.Description, LaunchURL: client.LaunchURL, HasLogo: client.HasLogo(), HasDarkLogo: client.HasDarkLogo(), diff --git a/backend/internal/service/oidc_service_test.go b/backend/internal/service/oidc_service_test.go index ab6470c5..5c9b4435 100644 --- a/backend/internal/service/oidc_service_test.go +++ b/backend/internal/service/oidc_service_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/storage" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" @@ -449,3 +450,91 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) { require.ErrorContains(t, err, "failed to look up client") }) } + +func TestOidcService_CreateClient_withDescription(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + + s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil) + require.NoError(t, err) + + description := "A test client description" + input := dto.OidcClientCreateDto{ + OidcClientUpdateDto: dto.OidcClientUpdateDto{ + Name: "Test Client", + Description: description, + CallbackURLs: []string{"https://example.com/callback"}, + }, + } + + client, err := s.CreateClient(t.Context(), input, "user-id") + require.NoError(t, err) + + var fetched model.OidcClient + err = db.First(&fetched, "id = ?", client.ID).Error + require.NoError(t, err) + require.NotEmpty(t, fetched.Description) + assert.Equal(t, description, fetched.Description) +} + +func TestOidcService_CreateClient_withoutDescription(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + + s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil) + require.NoError(t, err) + + input := dto.OidcClientCreateDto{ + OidcClientUpdateDto: dto.OidcClientUpdateDto{ + Name: "Test Client", + CallbackURLs: []string{"https://example.com/callback"}, + }, + } + + client, err := s.CreateClient(t.Context(), input, "user-id") + require.NoError(t, err) + + var fetched model.OidcClient + err = db.First(&fetched, "id = ?", client.ID).Error + require.NoError(t, err) + assert.Empty(t, fetched.Description) +} + +func TestOidcService_UpdateClient_description(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + + s, err := NewOidcService(db, nil, nil, nil, nil, nil, nil) + require.NoError(t, err) + + // Create a client without a description + client := model.OidcClient{ + Name: "Test Client", + CallbackURLs: model.UrlList{"https://example.com/callback"}, + } + err = db.Create(&client).Error + require.NoError(t, err) + + // Update with a description + description := "Updated description" + input := dto.OidcClientUpdateDto{ + Name: "Test Client", + Description: description, + CallbackURLs: []string{"https://example.com/callback"}, + } + + _, err = s.UpdateClient(t.Context(), client.ID, input) + require.NoError(t, err) + + var fetched model.OidcClient + err = db.First(&fetched, "id = ?", client.ID).Error + require.NoError(t, err) + require.NotEmpty(t, fetched.Description) + assert.Equal(t, description, fetched.Description) + + // Update to clear the description + input.Description = "" + _, err = s.UpdateClient(t.Context(), client.ID, input) + require.NoError(t, err) + + err = db.First(&fetched, "id = ?", client.ID).Error + require.NoError(t, err) + assert.Empty(t, fetched.Description) +} diff --git a/backend/resources/migrations/postgres/20260708120000_add_oidc_client_description.down.sql b/backend/resources/migrations/postgres/20260708120000_add_oidc_client_description.down.sql new file mode 100644 index 00000000..812af0d2 --- /dev/null +++ b/backend/resources/migrations/postgres/20260708120000_add_oidc_client_description.down.sql @@ -0,0 +1 @@ +ALTER TABLE oidc_clients DROP COLUMN description; diff --git a/backend/resources/migrations/postgres/20260708120000_add_oidc_client_description.up.sql b/backend/resources/migrations/postgres/20260708120000_add_oidc_client_description.up.sql new file mode 100644 index 00000000..03cff41f --- /dev/null +++ b/backend/resources/migrations/postgres/20260708120000_add_oidc_client_description.up.sql @@ -0,0 +1 @@ +ALTER TABLE oidc_clients ADD COLUMN description TEXT NOT NULL DEFAULT ''; diff --git a/backend/resources/migrations/sqlite/20260708120000_add_oidc_client_description.down.sql b/backend/resources/migrations/sqlite/20260708120000_add_oidc_client_description.down.sql new file mode 100644 index 00000000..f8479252 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260708120000_add_oidc_client_description.down.sql @@ -0,0 +1,7 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +ALTER TABLE oidc_clients DROP COLUMN description; + +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/backend/resources/migrations/sqlite/20260708120000_add_oidc_client_description.up.sql b/backend/resources/migrations/sqlite/20260708120000_add_oidc_client_description.up.sql new file mode 100644 index 00000000..1e5b3af4 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260708120000_add_oidc_client_description.up.sql @@ -0,0 +1,7 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +ALTER TABLE oidc_clients ADD COLUMN description TEXT NOT NULL DEFAULT ''; + +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index e638d05b..06ed68bd 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -455,6 +455,8 @@ "client_launch_url": "Client Launch URL", "client_launch_url_description": "The URL that will be opened when a user launches the app from the My Apps page.", "client_name_description": "The name of the client that shows in the Pocket ID UI.", + "client_description": "Description", + "client_description_description": "An optional description of the client that shows in the Pocket ID UI.", "revoke_access": "Revoke Access", "revoke_access_description": "Revoke access to {clientName}. {clientName} will no longer be able to access your account information.", "revoke_access_successful": "The access to {clientName} has been successfully revoked.", diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index d78f3a23..fb1380e7 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -3,6 +3,7 @@ import type { UserGroup } from './user-group.type'; export type OidcClientMetaData = { id: string; name: string; + description: string; hasLogo: boolean; hasDarkLogo: boolean; requiresReauthentication: boolean; diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte index 9da86c8c..7f618199 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte @@ -44,6 +44,7 @@ const client = { id: '', name: existingClient?.name || '', + description: existingClient?.description || '', callbackURLs: existingClient?.callbackURLs || [], logoutCallbackURLs: existingClient?.logoutCallbackURLs || [], isPublic: existingClient?.isPublic || false, @@ -73,6 +74,7 @@ .optional() ), name: z.string().min(2).max(50), + description: z.string().max(150), callbackURLs: z.array(callbackUrlSchema).default([]), logoutCallbackURLs: z.array(callbackUrlSchema).default([]), isPublic: z.boolean(), @@ -186,6 +188,12 @@ description={m.client_name_description()} bind:input={$inputs.name} /> + @@ -46,21 +46,28 @@ />
-
+

{client.name}

{#if client.launchURL}

{new URL(client.launchURL).hostname}

{/if} + {#if client.description} +

+ {client.description} +

+ {/if}
{#if $userStore?.isAdmin || client.lastUsedAt}
diff --git a/tests/data.ts b/tests/data.ts index 5eb0e5a0..1371f438 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -64,6 +64,7 @@ export const oidcClients = { }, pingvinShare: { name: 'Pingvin Share', + description: 'Self-hosted file sharing platform', callbackUrl: 'http://pingvin-share.localhost/auth/callback', secondCallbackUrl: 'http://pingvin-share.localhost/auth/callback2', launchURL: 'https://pingvin-share.local' diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json index 4e7e4d00..aeb7ba51 100644 --- a/tests/resources/export/database.json +++ b/tests/resources/export/database.json @@ -1,6 +1,6 @@ { "provider": "sqlite", - "version": 20260707170000, + "version": 20260708120000, "tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_api_permissions"], "tables": { "apis": [ @@ -92,6 +92,7 @@ "launch_url": "https://nextcloud.local", "logout_callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkLmxvY2FsaG9zdC9hdXRoL2xvZ291dC9jYWxsYmFjayJd", "name": "Nextcloud", + "description": "This is an example description for Nextcloud", "pkce_enabled": false, "pkce_supported": false, "requires_pushed_authorization_requests": false, @@ -112,6 +113,7 @@ "launch_url": null, "logout_callback_urls": "bnVsbA==", "name": "Immich", + "description": "", "pkce_enabled": false, "pkce_supported": false, "requires_pushed_authorization_requests": false, @@ -132,6 +134,7 @@ "launch_url": null, "logout_callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2xvZ291dC9jYWxsYmFjayJd", "name": "Tailscale", + "description": "", "pkce_enabled": false, "pkce_supported": false, "requires_pushed_authorization_requests": false, @@ -152,6 +155,7 @@ "launch_url": null, "logout_callback_urls": "bnVsbA==", "name": "Federated", + "description": "", "pkce_enabled": false, "pkce_supported": false, "requires_pushed_authorization_requests": false, @@ -171,6 +175,7 @@ "launch_url": null, "logout_callback_urls": "bnVsbA==", "name": "SCIM Client", + "description": "", "pkce_enabled": false, "pkce_supported": false, "requires_pushed_authorization_requests": false, @@ -191,6 +196,7 @@ "launch_url": null, "logout_callback_urls": "bnVsbA==", "name": "PAR Test Client", + "description": "", "pkce_enabled": false, "pkce_supported": false, "requires_pushed_authorization_requests": false, @@ -211,6 +217,7 @@ "launch_url": null, "logout_callback_urls": "bnVsbA==", "name": "Skip Consent Client", + "description": "", "pkce_enabled": false, "pkce_supported": false, "requires_pushed_authorization_requests": false, diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts index ca977807..ad41a640 100644 --- a/tests/specs/oidc-client-settings.spec.ts +++ b/tests/specs/oidc-client-settings.spec.ts @@ -11,6 +11,7 @@ test.describe('Create OIDC client', () => { await page.getByRole('button', { name: 'Add OIDC Client' }).click(); await page.getByLabel('Name').fill(oidcClient.name); + await page.getByLabel('Description').fill(oidcClient.description); await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL); await page.getByRole('button', { name: 'Add' }).first().click(); @@ -47,6 +48,7 @@ test.describe('Create OIDC client', () => { expect(clientSecret).toMatch(/^\w{32}$/); await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name); + await expect(page.getByLabel('Description')).toHaveValue(oidcClient.description); await expect(page.getByTestId('callback-url-1')).toHaveValue(oidcClient.callbackUrl); await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl); await expect(page.getByRole('img', { name: `${oidcClient.name} logo` }).first()).toBeVisible(); @@ -69,6 +71,7 @@ test('Edit OIDC client', async ({ page }) => { await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`); await page.getByLabel('Name').fill('Nextcloud updated'); + await page.getByLabel('Description').fill('Updated description'); await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback'); await page.locator('[role="tab"][data-value="light-logo"]').first().click(); await page.setInputFiles('#oidc-client-logo-light', 'resources/images/cloud-logo.png');