filters, page titles

This commit is contained in:
Evert Prants 2024-06-02 14:53:31 +03:00
parent c82ed0e9aa
commit 243fe982b1
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
16 changed files with 516 additions and 57 deletions

View File

@ -86,7 +86,7 @@
} }
.transfer-box > :global(.column):nth-child(2) { .transfer-box > :global(.column):nth-child(2) {
margin-top: 1.35rem; margin-top: 1.8rem;
} }
.transfer-box :global(.form-control) { .transfer-box :global(.form-control) {

View File

@ -8,7 +8,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 1.25rem; margin-bottom: 1.3rem;
} }
.title-row > :global(*):first-child { .title-row > :global(*):first-child {
margin: 0; margin: 0;

View File

@ -30,6 +30,7 @@
}, },
"oauth2": { "oauth2": {
"title": "OAuth2 applications", "title": "OAuth2 applications",
"new": "Create a new application",
"clientTitle": "Application name", "clientTitle": "Application name",
"clientId": "Client ID", "clientId": "Client ID",
"clientSecret": "Client secret", "clientSecret": "Client secret",
@ -64,7 +65,8 @@
"addHint": "You may assign application-specific privileges to your authorized users. You may use this system for permissions or for tagging your users, all up to you!", "addHint": "You may assign application-specific privileges to your authorized users. You may use this system for permissions or for tagging your users, all up to you!",
"remove": "Remove", "remove": "Remove",
"manage": "Manage user privileges", "manage": "Manage user privileges",
"new": "Add a new privilege" "new": "Add a new privilege",
"edit": "Edit privileges"
}, },
"urls": { "urls": {
"title": "Application URLs", "title": "Application URLs",
@ -79,6 +81,14 @@
}, },
"add": "Add URL" "add": "Add URL"
}, },
"apis": {
"title": "OAuth2 APIs",
"authorize": "OAuth2 Authorization endpoint",
"token": "OAuth2 Token endpoint",
"introspect": "OAuth2 Introspection endpoint",
"userinfo": "User information endpoint (Bearer)",
"openid": "OpenID Connect configuration"
},
"grantTexts": { "grantTexts": {
"authorization_code": "Authorization code", "authorization_code": "Authorization code",
"client_credentials": "Client credentials", "client_credentials": "Client credentials",

View File

@ -16,5 +16,6 @@
}, },
"available": "Available", "available": "Available",
"current": "Current", "current": "Current",
"remove": "Remove" "remove": "Remove",
"filter": "Filter"
} }

View File

@ -1,17 +1,20 @@
import { CryptoUtils } from '$lib/server/crypto-utils';
import { import {
db, db,
oauth2Client, oauth2Client,
oauth2ClientAuthorization,
oauth2ClientManager, oauth2ClientManager,
oauth2ClientUrl, oauth2ClientUrl,
privilege, privilege,
user, user,
userPrivilegesPrivilege,
type OAuth2Client, type OAuth2Client,
type OAuth2ClientUrl, type OAuth2ClientUrl,
type User type User
} from '$lib/server/drizzle'; } from '$lib/server/drizzle';
import { Uploads } from '$lib/server/upload'; import { Uploads } from '$lib/server/upload';
import type { PaginationMeta } from '$lib/types'; import type { PaginationMeta } from '$lib/types';
import { and, count, eq, ilike, like, or } from 'drizzle-orm'; import { and, count, eq, like, or, sql } from 'drizzle-orm';
export enum OAuth2ClientURLType { export enum OAuth2ClientURLType {
REDIRECT_URI = 'redirect_uri', REDIRECT_URI = 'redirect_uri',
@ -27,6 +30,17 @@ export interface OAuth2ClientAdminListItem
urls: Omit<OAuth2ClientUrl, 'created_at' | 'updated_at' | 'clientId'>[]; urls: Omit<OAuth2ClientUrl, 'created_at' | 'updated_at' | 'clientId'>[];
} }
export interface OAuth2AuthorizedUser {
uuid: string;
name: string;
current: boolean;
privileges: { id: number; name: string }[];
}
export interface OAuth2ManagerUser {
email: string;
}
export class OAuth2Clients { export class OAuth2Clients {
public static availableGrantTypes = [ public static availableGrantTypes = [
'authorization_code', 'authorization_code',
@ -109,6 +123,52 @@ export class OAuth2Clients {
)?.length; )?.length;
} }
static async getAuthorizedUsers(client: OAuth2Client, userUuid?: string) {
const junkList = await db
.select()
.from(oauth2ClientAuthorization)
.innerJoin(user, eq(user.id, oauth2ClientAuthorization.userId))
.leftJoin(userPrivilegesPrivilege, eq(userPrivilegesPrivilege.userId, user.id))
.leftJoin(
privilege,
and(
eq(userPrivilegesPrivilege.privilegeId, privilege.id),
eq(privilege.clientId, client.id)
)
)
.where(
and(
eq(oauth2ClientAuthorization.clientId, client.id),
userUuid ? eq(user.uuid, userUuid) : undefined
)
);
const list = junkList.reduce<OAuth2AuthorizedUser[]>((accum, dbo) => {
let user = accum.find((entry) => entry.uuid === dbo.user.uuid);
if (!user) {
user = {
uuid: dbo.user.uuid,
name: dbo.user.display_name,
current: false,
privileges: []
};
accum.push(user);
}
if (dbo.privilege && !user.privileges.some(({ id }) => id === dbo.privilege?.id)) {
user.privileges.push({ id: dbo.privilege.id, name: dbo.privilege.name });
}
if (dbo.o_auth2_client_authorization?.current === 1) {
user.current = true;
}
return accum;
}, []);
return list;
}
static checkSecret(client: OAuth2Client, secret: string) { static checkSecret(client: OAuth2Client, secret: string) {
return client.client_secret === secret; return client.client_secret === secret;
} }
@ -152,7 +212,7 @@ export class OAuth2Clients {
listAll?: boolean; listAll?: boolean;
} }
) { ) {
const filterText = `%${filters?.filter}%`; const filterText = `%${filters?.filter?.toLowerCase()}%`;
const limit = filters?.limit || 20; const limit = filters?.limit || 20;
const allowedClients = db const allowedClients = db
.select({ id: oauth2Client.id }) .select({ id: oauth2Client.id })
@ -165,7 +225,10 @@ export class OAuth2Clients {
: undefined, : undefined,
filters?.clientId ? eq(oauth2Client.client_id, filters.clientId) : undefined, filters?.clientId ? eq(oauth2Client.client_id, filters.clientId) : undefined,
filters?.filter filters?.filter
? or(ilike(oauth2Client.title, filterText), like(oauth2Client.client_id, filterText)) ? or(
sql`lower(${oauth2Client.title}) like ${filterText}`,
like(oauth2Client.client_id, filterText)
)
: undefined : undefined
) )
) )
@ -240,6 +303,32 @@ export class OAuth2Clients {
}; };
} }
static async createClient(subject: User, title: string, redirect: string, description?: string) {
const uid = CryptoUtils.createUUID();
const secret = CryptoUtils.generateSecret();
const [retval] = await db.insert(oauth2Client).values({
title,
description,
client_id: uid,
client_secret: secret,
grants: 'authorization_code',
scope: 'profile',
ownerId: subject.id,
created_at: new Date(),
activated: 1,
verified: 0
});
await db.insert(oauth2ClientUrl).values({
type: 'redirect_uri',
url: redirect,
clientId: retval.insertId
});
return uid;
}
static async deleteClient(client: OAuth2Client) { static async deleteClient(client: OAuth2Client) {
if (client.pictureId) { if (client.pictureId) {
await Uploads.removeClientAvatar(client); await Uploads.removeClientAvatar(client);

View File

@ -1,4 +1,4 @@
import { count, eq, ilike, like, or } from 'drizzle-orm'; import { count, eq, like, or, sql } from 'drizzle-orm';
import { import {
db, db,
privilege, privilege,
@ -58,9 +58,9 @@ export class UsersAdmin {
const searchExpression = filter const searchExpression = filter
? or( ? or(
like(user.uuid, subfilter), like(user.uuid, subfilter),
ilike(user.username, subfilter), sql`lower(${user.username}) like ${subfilter}`,
ilike(user.display_name, subfilter), sql`lower(${user.display_name}) like ${subfilter}`,
ilike(user.email, subfilter) sql`lower(${user.email}) like ${subfilter}`
) )
: undefined; : undefined;

View File

@ -241,13 +241,19 @@ export class Users {
); );
} }
static async setUserPrivileges(subject: User, privilegeIds: number[]) { static async setUserPrivileges(subject: User, privilegeIds: number[], clientId?: number) {
const current = await db const current = await db
.select({ .select({
privilegeId: userPrivilegesPrivilege.privilegeId privilegeId: userPrivilegesPrivilege.privilegeId
}) })
.from(userPrivilegesPrivilege) .from(userPrivilegesPrivilege)
.where(eq(userPrivilegesPrivilege.userId, subject.id)); .innerJoin(privilege, eq(privilege.id, userPrivilegesPrivilege.privilegeId))
.where(
and(
eq(userPrivilegesPrivilege.userId, subject.id),
clientId ? eq(privilege.clientId, clientId) : undefined
)
);
const toRemoveIds = current.reduce<number[]>( const toRemoveIds = current.reduce<number[]>(
(list, { privilegeId }) => (list, { privilegeId }) =>

View File

@ -3,21 +3,42 @@
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import ClientCard from '$lib/components/admin/AdminClientCard.svelte'; import ClientCard from '$lib/components/admin/AdminClientCard.svelte';
import TitleRow from '$lib/components/container/TitleRow.svelte';
import ColumnView from '$lib/components/container/ColumnView.svelte';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import FormControl from '$lib/components/form/FormControl.svelte';
import { page } from '$app/stores';
export let data: PageData; export let data: PageData;
</script> </script>
<h1>{$t('admin.oauth2.title')}</h1> <svelte:head>
<title>{$t('admin.oauth2.title')} - {PUBLIC_SITE_NAME} {$t('admin.title')}</title>
</svelte:head>
<div class="client-list"> <TitleRow>
<Paginator meta={data.meta} /> <h1>{$t('admin.oauth2.title')}</h1>
{#each data.list as client} <a href="oauth2/new">{$t('admin.oauth2.new')}</a>
<a href={`oauth2/${client.client_id}`} class="client-link"> </TitleRow>
<ClientCard {client} />
</a> <ColumnView>
{/each} <form action="" method="get">
<Paginator meta={data.meta} /> <FormControl>
</div> <label for="filter">{$t('common.filter')}</label>
<input name="filter" value={$page.url.searchParams.get('filter')} />
</FormControl>
</form>
<div class="client-list">
<Paginator meta={data.meta} />
{#each data.list as client}
<a href={`oauth2/${client.client_id}`} class="client-link">
<ClientCard {client} />
</a>
{/each}
<Paginator meta={data.meta} />
</div>
</ColumnView>
<style> <style>
.client-link { .client-link {

View File

@ -32,11 +32,13 @@ export const actions = {
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const { const {
list: [details] list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { } = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid, clientId: uuid,
listAll: false, listAll: fullPrivileges,
omitSecret: false omitSecret: false
}); });
@ -44,8 +46,6 @@ export const actions = {
return error(404, 'Client not found'); return error(404, 'Client not found');
} }
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const body = await request.formData(); const body = await request.formData();
const { title, description, activated, verified } = Changesets.take<UpdateRequest>( const { title, description, activated, verified } = Changesets.take<UpdateRequest>(
['title', 'description', 'activated', 'verified'], ['title', 'description', 'activated', 'verified'],
@ -81,11 +81,13 @@ export const actions = {
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const { const {
list: [details] list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { } = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid, clientId: uuid,
listAll: false, listAll: fullPrivileges,
omitSecret: false omitSecret: false
}); });
@ -97,7 +99,6 @@ export const actions = {
return fail(400, { errors: ['deleteActivated'] }); return fail(400, { errors: ['deleteActivated'] });
} }
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
if (details.ownerId !== currentUser.id && !fullPrivileges) { if (details.ownerId !== currentUser.id && !fullPrivileges) {
return fail(403, { errors: ['forbidden'] }); return fail(403, { errors: ['forbidden'] });
} }
@ -111,11 +112,13 @@ export const actions = {
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const { const {
list: [details] list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { } = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid, clientId: uuid,
listAll: false, listAll: fullPrivileges,
omitSecret: false omitSecret: false
}); });
@ -123,7 +126,6 @@ export const actions = {
return error(404, 'Client not found'); return error(404, 'Client not found');
} }
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
if (!fullPrivileges && !details.isOwner) { if (!fullPrivileges && !details.isOwner) {
return fail(403, { errors: ['forbidden'] }); return fail(403, { errors: ['forbidden'] });
} }
@ -135,15 +137,17 @@ export const actions = {
return { errors: [] }; return { errors: [] };
}, },
removeUrl: async ({ locals, url, params: { uuid } }) => { removeUrl: async ({ locals, url, params: { uuid } }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [ const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const { const {
list: [details] list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { } = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid, clientId: uuid,
listAll: false, listAll: fullPrivileges,
omitSecret: false omitSecret: false
}); });
@ -161,15 +165,17 @@ export const actions = {
return { errors: [] }; return { errors: [] };
}, },
addUrl: async ({ locals, request, params: { uuid } }) => { addUrl: async ({ locals, request, params: { uuid } }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [ const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const { const {
list: [details] list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { } = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid, clientId: uuid,
listAll: false, listAll: fullPrivileges,
omitSecret: false omitSecret: false
}); });
@ -192,15 +198,17 @@ export const actions = {
return { errors: [] }; return { errors: [] };
}, },
removePrivilege: async ({ locals, url, params: { uuid } }) => { removePrivilege: async ({ locals, url, params: { uuid } }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [ const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const { const {
list: [details] list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { } = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid, clientId: uuid,
listAll: false, listAll: fullPrivileges,
omitSecret: false omitSecret: false
}); });
@ -218,15 +226,17 @@ export const actions = {
return { errors: [] }; return { errors: [] };
}, },
addPrivilege: async ({ locals, request, params: { uuid } }) => { addPrivilege: async ({ locals, request, params: { uuid } }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [ const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const { const {
list: [details] list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { } = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid, clientId: uuid,
listAll: false, listAll: fullPrivileges,
omitSecret: false omitSecret: false
}); });
@ -250,11 +260,13 @@ export const actions = {
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const { const {
list: [details] list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { } = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid, clientId: uuid,
listAll: false, listAll: fullPrivileges,
omitSecret: false omitSecret: false
}); });
@ -262,7 +274,6 @@ export const actions = {
return error(404, 'Client not found'); return error(404, 'Client not found');
} }
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const allowedGrants = fullPrivileges const allowedGrants = fullPrivileges
? OAuth2Clients.availableGrantTypes ? OAuth2Clients.availableGrantTypes
: OAuth2Clients.userSetGrants; : OAuth2Clients.userSetGrants;
@ -285,11 +296,13 @@ export const actions = {
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const { const {
list: [details] list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { } = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid, clientId: uuid,
listAll: false, listAll: fullPrivileges,
omitSecret: false omitSecret: false
}); });
@ -297,7 +310,6 @@ export const actions = {
return error(404, 'Client not found'); return error(404, 'Client not found');
} }
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const allowedScopes = fullPrivileges const allowedScopes = fullPrivileges
? OAuth2Clients.availableScopes ? OAuth2Clients.availableScopes
: OAuth2Clients.userSetScopes; : OAuth2Clients.userSetScopes;
@ -316,15 +328,17 @@ export const actions = {
return { errors: [] }; return { errors: [] };
}, },
avatar: async ({ request, locals, params: { uuid } }) => { avatar: async ({ request, locals, params: { uuid } }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [ const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const { const {
list: [details] list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { } = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid, clientId: uuid,
listAll: false, listAll: fullPrivileges,
omitSecret: false omitSecret: false
}); });
@ -346,15 +360,17 @@ export const actions = {
return { errors: [] }; return { errors: [] };
}, },
removeAvatar: async ({ locals, params: { uuid } }) => { removeAvatar: async ({ locals, params: { uuid } }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [ const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const { const {
list: [details] list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { } = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid, clientId: uuid,
listAll: false, listAll: fullPrivileges,
omitSecret: false omitSecret: false
}); });
@ -379,7 +395,7 @@ export const load = async ({ params: { uuid }, parent }) => {
list: [details] list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { } = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid, clientId: uuid,
listAll: false, listAll: fullPrivileges,
omitSecret: false omitSecret: false
}); });
@ -388,8 +404,10 @@ export const load = async ({ params: { uuid }, parent }) => {
} }
const privileges = await Users.getAvailablePrivileges(details.id); const privileges = await Users.getAvailablePrivileges(details.id);
const users = await OAuth2Clients.getAuthorizedUsers(details as OAuth2Client);
return { return {
users,
availableUrls: OAuth2Clients.availableUrlTypes, availableUrls: OAuth2Clients.availableUrlTypes,
availablePrivileges: privileges, availablePrivileges: privileges,
availableGrants: fullPrivileges availableGrants: fullPrivileges

View File

@ -14,6 +14,7 @@
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import FormErrors from '$lib/components/form/FormErrors.svelte'; import FormErrors from '$lib/components/form/FormErrors.svelte';
import { PUBLIC_SITE_NAME, PUBLIC_URL } from '$env/static/public';
export let data: PageData; export let data: PageData;
export let form: ActionData; export let form: ActionData;
@ -37,6 +38,13 @@
$: uuidPrefix = data.details.client_id.split('-')[0] + ':'; $: uuidPrefix = data.details.client_id.split('-')[0] + ':';
</script> </script>
<svelte:head>
<title
>{$t('admin.oauth2.title')} / {data.details.title} - {PUBLIC_SITE_NAME}
{$t('admin.title')}</title
>
</svelte:head>
<h1>{$t('admin.oauth2.title')} / {data.details.title}</h1> <h1>{$t('admin.oauth2.title')} / {data.details.title}</h1>
<ColumnView> <ColumnView>
@ -282,16 +290,74 @@
<Button type="submit" variant="primary">{$t('common.submit')}</Button> <Button type="submit" variant="primary">{$t('common.submit')}</Button>
</form> </form>
<h2>{$t('admin.oauth2.apis.title')}</h2>
<ul>
<li>
{$t('admin.oauth2.apis.authorize')} -
<code
><a href={`/oauth2/authorize`} data-sveltekit-preload-data="off"
>{PUBLIC_URL}/oauth2/authorize</a
></code
>
</li>
<li>
{$t('admin.oauth2.apis.token')} -
<code
><a href={`/oauth2/token`} data-sveltekit-preload-data="off">{PUBLIC_URL}/oauth2/token</a
></code
>
</li>
<li>
{$t('admin.oauth2.apis.introspect')} -
<code
><a href={`/oauth2/introspect`} data-sveltekit-preload-data="off"
>{PUBLIC_URL}/oauth2/introspect</a
></code
>
</li>
<li>
{$t('admin.oauth2.apis.userinfo')} -
<code><a href={`/api/user`} data-sveltekit-preload-data="off">{PUBLIC_URL}/api/user</a></code>
</li>
<li>
{$t('admin.oauth2.apis.openid')} -
<code
><a href={`/.well-known/openid-configuration`} data-sveltekit-preload-data="off"
>{PUBLIC_URL}/.well-known/openid-configuration</a
></code
>
</li>
</ul>
<h2>{$t('admin.oauth2.authorizations')}</h2> <h2>{$t('admin.oauth2.authorizations')}</h2>
<p>{$t('admin.oauth2.authorizationsHint')}</p> <p>{$t('admin.oauth2.authorizationsHint')}</p>
<b>{$t('admin.oauth2.noAuthorizations')}</b> <div class="addremove">
{#each data.users as user}
<div class="auth-user addremove-item">
<div>
<div class="auth-user-name">
<b>{user.uuid}</b> ({user.name})
</div>
<div class="auth-user-privileges">
{user.privileges.map(({ name }) => name).join(', ')}
</div>
{#if !user.current}
<b>{$t('admin.oauth2.revoked')}</b>
{/if}
</div>
<a href={`${$page.url.pathname}/user/${user.uuid}`}>{$t('admin.oauth2.privileges.edit')}</a>
</div>
{:else}
<b>{$t('admin.oauth2.noAuthorizations')}</b>
{/each}
</div>
</ColumnView> </ColumnView>
<AvatarModal show={showAvatarModal} url={$page.url.pathname} /> <AvatarModal show={showAvatarModal} url={$page.url.pathname} />
<style> <style>
h2, h2,
ul,
p { p {
margin: 0; margin: 0;
} }

View File

@ -0,0 +1,94 @@
import { AdminUtils } from '$lib/server/admin-utils';
import { Changesets } from '$lib/server/changesets.js';
import type { OAuth2Client, User } from '$lib/server/drizzle';
import { OAuth2Clients } from '$lib/server/oauth2';
import { Users } from '$lib/server/users';
import { UsersAdmin } from '$lib/server/users/admin';
import { error, redirect } from '@sveltejs/kit';
interface PrivilegesRequest {
privileges: string;
}
export const actions = {
privileges: async ({ locals, params: { uuid, user: userId }, request }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const [auth] = await OAuth2Clients.getAuthorizedUsers(details as OAuth2Client, userId);
if (!auth) {
return error(404, 'User not found');
}
const targetUser = await Users.getByUuid(userId);
if (!targetUser) {
return error(404, 'User not found');
}
const availablePrivileges = await Users.getAvailablePrivileges(details.id);
const body = await request.formData();
const { privileges } = Changesets.take<PrivilegesRequest>(['privileges'], body);
const splitFilter = (privileges || '').split(',').reduce<number[]>((final, id) => {
const privId = Number(id);
if (
isNaN(privId) ||
final.includes(privId) ||
!availablePrivileges.some((entry) => entry.id === privId)
) {
return final;
}
return [...final, privId];
}, []);
await Users.setUserPrivileges(targetUser, splitFilter, details.id);
return redirect(303, '..');
}
};
export const load = async ({ params: { uuid, user: userId }, parent }) => {
const { user } = await parent();
const currentUser = await Users.getBySession(user);
AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2']]);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: false,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const [auth] = await OAuth2Clients.getAuthorizedUsers(details as OAuth2Client, userId);
if (!auth) {
return error(404, 'User not found');
}
const availablePrivileges = await Users.getAvailablePrivileges(details.id);
return {
auth,
details,
availablePrivileges
};
};

View File

@ -0,0 +1,38 @@
<script lang="ts">
import { PUBLIC_SITE_NAME } from '$env/static/public';
import Button from '$lib/components/Button.svelte';
import AdminPrivilegesSelect from '$lib/components/admin/AdminPrivilegesSelect.svelte';
import ButtonRow from '$lib/components/container/ButtonRow.svelte';
import FormControl from '$lib/components/form/FormControl.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import { t } from '$lib/i18n';
import type { PageData } from './$types';
export let data: PageData;
</script>
<svelte:head>
<title
>{$t('admin.oauth2.privileges.edit')} / {data.details.title} - {PUBLIC_SITE_NAME}
{$t('admin.title')}</title
>
</svelte:head>
<h1>{$t('admin.oauth2.privileges.edit')} / {data.details.title}</h1>
<h2>{data.auth.name}</h2>
<form action="?/privileges" method="POST">
<FormWrapper>
<FormControl>
<label for="form-uuid">{$t('admin.users.uuid')}</label>
<input id="form-uuid" disabled value={data.auth.uuid} />
</FormControl>
<AdminPrivilegesSelect available={data.availablePrivileges} current={data.auth.privileges} />
<ButtonRow>
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
<a href="../">{$t('common.cancel')}</a>
</ButtonRow>
</FormWrapper>
</form>

View File

@ -0,0 +1,48 @@
import { AdminUtils } from '$lib/server/admin-utils';
import { Changesets } from '$lib/server/changesets.js';
import { OAuth2Clients } from '$lib/server/oauth2/index.js';
import { UsersAdmin } from '$lib/server/users/admin';
import { fail, redirect } from '@sveltejs/kit';
interface CreateClientRequest {
title: string;
description: string;
redirectUri: string;
}
export const actions = {
default: async ({ locals, request }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [
['admin:oauth2', 'self:oauth2']
]);
const body = await request.formData();
const { title, description, redirectUri } = Changesets.take<CreateClientRequest>(
['title', 'description', 'redirectUri'],
body
);
if (!title || title.length < 3 || title.length > 32) {
return fail(400, { errors: ['invalidTitle'] });
}
if (description && description.length > 1000) {
return fail(400, { errors: ['invalidDescription'] });
}
if (!redirectUri) {
return fail(400, { errors: ['noRedirect'] });
}
const uuid = await OAuth2Clients.createClient(currentUser, title, redirectUri, description);
return redirect(303, `/ssoadmin/oauth2/${uuid}`);
}
};
export const load = async ({ parent }) => {
const { user } = await parent();
AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2']]);
return {};
};

View File

@ -0,0 +1,43 @@
<script lang="ts">
import FormControl from '$lib/components/form/FormControl.svelte';
import FormSection from '$lib/components/form/FormSection.svelte';
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
import FormErrors from '$lib/components/form/FormErrors.svelte';
import Button from '$lib/components/Button.svelte';
import { t } from '$lib/i18n';
import type { ActionData } from './$types';
import { PUBLIC_SITE_NAME } from '$env/static/public';
export let form: ActionData;
</script>
<svelte:head>
<title>{$t('admin.oauth2.new')} - {PUBLIC_SITE_NAME} {$t('admin.title')}</title>
</svelte:head>
<h1>{$t('admin.oauth2.new')}</h1>
<form action="" method="POST">
<FormWrapper>
<FormErrors errors={form?.errors || []} prefix="admin.oauth2.errors" />
<FormSection required>
<FormControl>
<label for="form-title">{$t('admin.oauth2.clientTitle')}</label>
<input name="title" id="form-title" required />
</FormControl>
<FormControl>
<label for="form-redirectUri">{$t('admin.oauth2.urls.types.redirect_uri')}</label>
<input name="redirectUri" type="url" id="form-redirectUri" required />
</FormControl>
<FormControl>
<label for="client-description">{$t('admin.oauth2.description')}</label>
<textarea name="description" id="client-description" rows="3" />
</FormControl>
</FormSection>
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
</FormWrapper>
</form>

View File

@ -3,21 +3,38 @@
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import type { PageData } from './$types'; import type { PageData } from './$types';
import UserCard from '$lib/components/admin/AdminUserCard.svelte'; import UserCard from '$lib/components/admin/AdminUserCard.svelte';
import { PUBLIC_SITE_NAME } from '$env/static/public';
import FormControl from '$lib/components/form/FormControl.svelte';
import ColumnView from '$lib/components/container/ColumnView.svelte';
import { page } from '$app/stores';
export let data: PageData; export let data: PageData;
</script> </script>
<svelte:head>
<title>{$t('admin.users.title')} - {PUBLIC_SITE_NAME} {$t('admin.title')}</title>
</svelte:head>
<h1>{$t('admin.users.title')}</h1> <h1>{$t('admin.users.title')}</h1>
<div class="user-list"> <ColumnView>
<Paginator meta={data.meta} /> <form action="" method="get">
{#each data.list as user} <FormControl>
<a href={`users/${user.uuid}`} class="user-link"> <label for="filter">{$t('common.filter')}</label>
<UserCard {user} /> <input name="filter" value={$page.url.searchParams.get('filter')} />
</a> </FormControl>
{/each} </form>
<Paginator meta={data.meta} />
</div> <div class="user-list">
<Paginator meta={data.meta} />
{#each data.list as user}
<a href={`users/${user.uuid}`} class="user-link">
<UserCard {user} />
</a>
{/each}
<Paginator meta={data.meta} />
</div>
</ColumnView>
<style> <style>
.user-link { .user-link {

View File

@ -10,11 +10,19 @@
import type { ActionData, PageData } from './$types'; import type { ActionData, PageData } from './$types';
import AdminPrivilegesSelect from '$lib/components/admin/AdminPrivilegesSelect.svelte'; import AdminPrivilegesSelect from '$lib/components/admin/AdminPrivilegesSelect.svelte';
import FormErrors from '$lib/components/form/FormErrors.svelte'; import FormErrors from '$lib/components/form/FormErrors.svelte';
import { PUBLIC_SITE_NAME } from '$env/static/public';
export let data: PageData; export let data: PageData;
export let form: ActionData; export let form: ActionData;
</script> </script>
<svelte:head>
<title
>{$t('admin.users.title')} / {data.details.display_name} - {PUBLIC_SITE_NAME}
{$t('admin.title')}</title
>
</svelte:head>
<h1>{$t('admin.users.title')} / {data.details.display_name}</h1> <h1>{$t('admin.users.title')} / {data.details.display_name}</h1>
<SplitView> <SplitView>