feat: add description field to oidc clients (#1547)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Sean McKenzie
2026-07-08 05:41:51 -06:00
committed by GitHub
parent c85a4e63da
commit 6734585712
16 changed files with 149 additions and 9 deletions

View File

@@ -5,6 +5,7 @@ import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type OidcClientMetaDataDto struct { type OidcClientMetaDataDto struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"`
HasLogo bool `json:"hasLogo"` HasLogo bool `json:"hasLogo"`
HasDarkLogo bool `json:"hasDarkLogo"` HasDarkLogo bool `json:"hasDarkLogo"`
LaunchURL *string `json:"launchURL"` LaunchURL *string `json:"launchURL"`
@@ -36,6 +37,7 @@ type OidcClientWithAllowedGroupsCountDto struct {
type OidcClientUpdateDto struct { type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"` 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"` CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"` LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`

View File

@@ -23,6 +23,7 @@ type OidcClient struct {
Base Base
Name string `sortable:"true"` Name string `sortable:"true"`
Description string
Secret string Secret string
CallbackURLs UrlList CallbackURLs UrlList
LogoutCallbackURLs UrlList LogoutCallbackURLs UrlList

View File

@@ -187,6 +187,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055", ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
}, },
Name: "Nextcloud", Name: "Nextcloud",
Description: "This is an example description for Nextcloud",
LaunchURL: new("https://nextcloud.local"), LaunchURL: new("https://nextcloud.local"),
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://nextcloud.localhost/auth/callback"}, CallbackURLs: model.UrlList{"http://nextcloud.localhost/auth/callback"},

View File

@@ -213,6 +213,7 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) { func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) {
// Base fields // Base fields
client.Name = input.Name client.Name = input.Name
client.Description = input.Description
client.CallbackURLs = input.CallbackURLs client.CallbackURLs = input.CallbackURLs
client.LogoutCallbackURLs = input.LogoutCallbackURLs client.LogoutCallbackURLs = input.LogoutCallbackURLs
client.IsPublic = input.IsPublic 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 user has no groups, only return clients with no allowed user groups
if len(userGroupIDs) == 0 { if len(userGroupIDs) == 0 {
query = query.Where(`NOT EXISTS ( 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)`) WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id)`)
} else { } else {
query = query.Where(` query = query.Where(`
NOT EXISTS ( 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 WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
) OR EXISTS ( ) OR 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 WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, userGroupIDs) 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{ OidcClientMetaDataDto: dto.OidcClientMetaDataDto{
ID: client.ID, ID: client.ID,
Name: client.Name, Name: client.Name,
Description: client.Description,
LaunchURL: client.LaunchURL, LaunchURL: client.LaunchURL,
HasLogo: client.HasLogo(), HasLogo: client.HasLogo(),
HasDarkLogo: client.HasDarkLogo(), HasDarkLogo: client.HasDarkLogo(),

View File

@@ -11,6 +11,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common" "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/model"
"github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" 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") 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)
}

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN description;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN description TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1,7 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_clients DROP COLUMN description;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -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;

View File

@@ -455,6 +455,8 @@
"client_launch_url": "Client Launch URL", "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_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_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": "Revoke Access",
"revoke_access_description": "Revoke access to <b>{clientName}</b>. <b>{clientName}</b> will no longer be able to access your account information.", "revoke_access_description": "Revoke access to <b>{clientName}</b>. <b>{clientName}</b> will no longer be able to access your account information.",
"revoke_access_successful": "The access to {clientName} has been successfully revoked.", "revoke_access_successful": "The access to {clientName} has been successfully revoked.",

View File

@@ -3,6 +3,7 @@ import type { UserGroup } from './user-group.type';
export type OidcClientMetaData = { export type OidcClientMetaData = {
id: string; id: string;
name: string; name: string;
description: string;
hasLogo: boolean; hasLogo: boolean;
hasDarkLogo: boolean; hasDarkLogo: boolean;
requiresReauthentication: boolean; requiresReauthentication: boolean;

View File

@@ -44,6 +44,7 @@
const client = { const client = {
id: '', id: '',
name: existingClient?.name || '', name: existingClient?.name || '',
description: existingClient?.description || '',
callbackURLs: existingClient?.callbackURLs || [], callbackURLs: existingClient?.callbackURLs || [],
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [], logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
isPublic: existingClient?.isPublic || false, isPublic: existingClient?.isPublic || false,
@@ -73,6 +74,7 @@
.optional() .optional()
), ),
name: z.string().min(2).max(50), name: z.string().min(2).max(50),
description: z.string().max(150),
callbackURLs: z.array(callbackUrlSchema).default([]), callbackURLs: z.array(callbackUrlSchema).default([]),
logoutCallbackURLs: z.array(callbackUrlSchema).default([]), logoutCallbackURLs: z.array(callbackUrlSchema).default([]),
isPublic: z.boolean(), isPublic: z.boolean(),
@@ -186,6 +188,12 @@
description={m.client_name_description()} description={m.client_name_description()}
bind:input={$inputs.name} bind:input={$inputs.name}
/> />
<FormInput
label={m.client_description()}
class="w-full"
description={m.client_description_description()}
bind:input={$inputs.description}
/>
<FormInput <FormInput
label={m.client_launch_url()} label={m.client_launch_url()}
description={m.client_launch_url_description()} description={m.client_launch_url_description()}

View File

@@ -31,7 +31,7 @@
</script> </script>
<Card.Root <Card.Root
class="border-muted group relative h-[140px] p-5 transition-all duration-200 hover:shadow-md sm:max-w-[50vw] md:max-w-[400px]" class="border-muted group relative h-[160px] p-5 transition-all duration-200 hover:shadow-md sm:max-w-[50vw] md:max-w-[400px]"
data-testid="authorized-oidc-client-card" data-testid="authorized-oidc-client-card"
> >
<Card.Content class=" p-0"> <Card.Content class=" p-0">
@@ -46,21 +46,28 @@
/> />
</div> </div>
<div class="flex w-full justify-between gap-3"> <div class="flex w-full justify-between gap-3">
<div> <div class="h-20">
<div class="mb-1 flex items-start gap-2"> <div class="mb-1 flex items-start gap-2">
<h3 <h3
class="text-foreground line-clamp-2 leading-tight font-semibold break-words break-all text-ellipsis" class="text-foreground line-clamp-2 leading-tight font-semibold wrap-break-word break-all text-ellipsis"
> >
{client.name} {client.name}
</h3> </h3>
</div> </div>
{#if client.launchURL} {#if client.launchURL}
<p <p
class="text-muted-foreground line-clamp-1 text-xs break-words break-all text-ellipsis" class="text-muted-foreground line-clamp-1 text-xs wrap-break-word break-all text-ellipsis"
> >
{new URL(client.launchURL).hostname} {new URL(client.launchURL).hostname}
</p> </p>
{/if} {/if}
{#if client.description}
<p
class="text-muted-foreground line-clamp-3 wrap-break-word text-ellipsis text-xs mt-1"
>
{client.description}
</p>
{/if}
</div> </div>
{#if $userStore?.isAdmin || client.lastUsedAt} {#if $userStore?.isAdmin || client.lastUsedAt}
<div> <div>

View File

@@ -64,6 +64,7 @@ export const oidcClients = {
}, },
pingvinShare: { pingvinShare: {
name: 'Pingvin Share', name: 'Pingvin Share',
description: 'Self-hosted file sharing platform',
callbackUrl: 'http://pingvin-share.localhost/auth/callback', callbackUrl: 'http://pingvin-share.localhost/auth/callback',
secondCallbackUrl: 'http://pingvin-share.localhost/auth/callback2', secondCallbackUrl: 'http://pingvin-share.localhost/auth/callback2',
launchURL: 'https://pingvin-share.local' launchURL: 'https://pingvin-share.local'

View File

@@ -1,6 +1,6 @@
{ {
"provider": "sqlite", "provider": "sqlite",
"version": 20260707170000, "version": 20260708120000,
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_api_permissions"], "tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_api_permissions"],
"tables": { "tables": {
"apis": [ "apis": [
@@ -92,6 +92,7 @@
"launch_url": "https://nextcloud.local", "launch_url": "https://nextcloud.local",
"logout_callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkLmxvY2FsaG9zdC9hdXRoL2xvZ291dC9jYWxsYmFjayJd", "logout_callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkLmxvY2FsaG9zdC9hdXRoL2xvZ291dC9jYWxsYmFjayJd",
"name": "Nextcloud", "name": "Nextcloud",
"description": "This is an example description for Nextcloud",
"pkce_enabled": false, "pkce_enabled": false,
"pkce_supported": false, "pkce_supported": false,
"requires_pushed_authorization_requests": false, "requires_pushed_authorization_requests": false,
@@ -112,6 +113,7 @@
"launch_url": null, "launch_url": null,
"logout_callback_urls": "bnVsbA==", "logout_callback_urls": "bnVsbA==",
"name": "Immich", "name": "Immich",
"description": "",
"pkce_enabled": false, "pkce_enabled": false,
"pkce_supported": false, "pkce_supported": false,
"requires_pushed_authorization_requests": false, "requires_pushed_authorization_requests": false,
@@ -132,6 +134,7 @@
"launch_url": null, "launch_url": null,
"logout_callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2xvZ291dC9jYWxsYmFjayJd", "logout_callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2xvZ291dC9jYWxsYmFjayJd",
"name": "Tailscale", "name": "Tailscale",
"description": "",
"pkce_enabled": false, "pkce_enabled": false,
"pkce_supported": false, "pkce_supported": false,
"requires_pushed_authorization_requests": false, "requires_pushed_authorization_requests": false,
@@ -152,6 +155,7 @@
"launch_url": null, "launch_url": null,
"logout_callback_urls": "bnVsbA==", "logout_callback_urls": "bnVsbA==",
"name": "Federated", "name": "Federated",
"description": "",
"pkce_enabled": false, "pkce_enabled": false,
"pkce_supported": false, "pkce_supported": false,
"requires_pushed_authorization_requests": false, "requires_pushed_authorization_requests": false,
@@ -171,6 +175,7 @@
"launch_url": null, "launch_url": null,
"logout_callback_urls": "bnVsbA==", "logout_callback_urls": "bnVsbA==",
"name": "SCIM Client", "name": "SCIM Client",
"description": "",
"pkce_enabled": false, "pkce_enabled": false,
"pkce_supported": false, "pkce_supported": false,
"requires_pushed_authorization_requests": false, "requires_pushed_authorization_requests": false,
@@ -191,6 +196,7 @@
"launch_url": null, "launch_url": null,
"logout_callback_urls": "bnVsbA==", "logout_callback_urls": "bnVsbA==",
"name": "PAR Test Client", "name": "PAR Test Client",
"description": "",
"pkce_enabled": false, "pkce_enabled": false,
"pkce_supported": false, "pkce_supported": false,
"requires_pushed_authorization_requests": false, "requires_pushed_authorization_requests": false,
@@ -211,6 +217,7 @@
"launch_url": null, "launch_url": null,
"logout_callback_urls": "bnVsbA==", "logout_callback_urls": "bnVsbA==",
"name": "Skip Consent Client", "name": "Skip Consent Client",
"description": "",
"pkce_enabled": false, "pkce_enabled": false,
"pkce_supported": false, "pkce_supported": false,
"requires_pushed_authorization_requests": false, "requires_pushed_authorization_requests": false,

View File

@@ -11,6 +11,7 @@ test.describe('Create OIDC client', () => {
await page.getByRole('button', { name: 'Add OIDC Client' }).click(); await page.getByRole('button', { name: 'Add OIDC Client' }).click();
await page.getByLabel('Name').fill(oidcClient.name); 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.getByLabel('Client Launch URL').fill(oidcClient.launchURL);
await page.getByRole('button', { name: 'Add' }).first().click(); await page.getByRole('button', { name: 'Add' }).first().click();
@@ -47,6 +48,7 @@ test.describe('Create OIDC client', () => {
expect(clientSecret).toMatch(/^\w{32}$/); expect(clientSecret).toMatch(/^\w{32}$/);
await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name); 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-1')).toHaveValue(oidcClient.callbackUrl);
await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl); await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl);
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` }).first()).toBeVisible(); 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.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
await page.getByLabel('Name').fill('Nextcloud updated'); 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.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
await page.locator('[role="tab"][data-value="light-logo"]').first().click(); await page.locator('[role="tab"][data-value="light-logo"]').first().click();
await page.setInputFiles('#oidc-client-logo-light', 'resources/images/cloud-logo.png'); await page.setInputFiles('#oidc-client-logo-light', 'resources/images/cloud-logo.png');