mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-15 21:48:13 +03:00
feat: add description field to oidc clients (#1547)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -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"`
|
||||
|
||||
@@ -23,6 +23,7 @@ type OidcClient struct {
|
||||
Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Description string
|
||||
Secret string
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_clients DROP COLUMN description;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_clients ADD COLUMN description TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,7 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE oidc_clients DROP COLUMN description;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -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;
|
||||
@@ -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 <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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<FormInput
|
||||
label={m.client_description()}
|
||||
class="w-full"
|
||||
description={m.client_description_description()}
|
||||
bind:input={$inputs.description}
|
||||
/>
|
||||
<FormInput
|
||||
label={m.client_launch_url()}
|
||||
description={m.client_launch_url_description()}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</script>
|
||||
|
||||
<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"
|
||||
>
|
||||
<Card.Content class=" p-0">
|
||||
@@ -46,21 +46,28 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full justify-between gap-3">
|
||||
<div>
|
||||
<div class="h-20">
|
||||
<div class="mb-1 flex items-start gap-2">
|
||||
<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}
|
||||
</h3>
|
||||
</div>
|
||||
{#if client.launchURL}
|
||||
<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}
|
||||
</p>
|
||||
{/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>
|
||||
{#if $userStore?.isAdmin || client.lastUsedAt}
|
||||
<div>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user