oauth2 client adminning
This commit is contained in:
parent
d258880ac4
commit
d11403a073
101
src/lib/components/admin/AdminClientCard.svelte
Normal file
101
src/lib/components/admin/AdminClientCard.svelte
Normal file
@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import type { PageData } from '../../../routes/ssoadmin/oauth2/$types';
|
||||
|
||||
export let client: PageData['list'][0];
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeStyle: 'medium' });
|
||||
const formatDate = dateFormat.format.bind(null);
|
||||
</script>
|
||||
|
||||
<div class="client">
|
||||
<div class="client-avatar-wrapper">
|
||||
<img class="client-avatar" src={`/api/avatar/client/${client.client_id}`} alt={client.title} />
|
||||
</div>
|
||||
|
||||
<div class="client-info">
|
||||
<h2 class="client-name">{client.title}</h2>
|
||||
<span class="client-description">{client.description}</span>
|
||||
|
||||
<dl>
|
||||
<dt>{$t('admin.oauth2.clientId')}</dt>
|
||||
<dd>{client.client_id}</dd>
|
||||
|
||||
<dt>{$t('admin.oauth2.scopes')}</dt>
|
||||
<dd>{client.scope}</dd>
|
||||
|
||||
<dt>{$t('admin.oauth2.grants')}</dt>
|
||||
<dd>{client.grants}</dd>
|
||||
|
||||
<dt>{$t('admin.oauth2.activated')}</dt>
|
||||
<dd>{$t(`common.bool.${Boolean(client.activated)}`)}</dd>
|
||||
|
||||
<dt>{$t('admin.oauth2.verified')}</dt>
|
||||
<dd>{$t(`common.bool.${Boolean(client.verified)}`)}</dd>
|
||||
|
||||
<dt>{$t('admin.oauth2.created')}</dt>
|
||||
<dd>{formatDate(client.created_at)}</dd>
|
||||
|
||||
<dt>{$t('admin.oauth2.owner')}</dt>
|
||||
<dd>
|
||||
{client.ownerInfo?.uuid} ({client.ownerInfo?.name})
|
||||
{#if client.isOwner}<i> - {$t('admin.oauth2.ownerMe')}</i>{/if}
|
||||
</dd>
|
||||
|
||||
<dt>{$t('admin.oauth2.urls.title')}</dt>
|
||||
<dd>
|
||||
{#each client.urls as url}
|
||||
<a href={url.url} target="_blank" rel="nofollow noreferrer" class="client-url"
|
||||
>{$t(`admin.oauth2.urls.types.${url.type}`)} <{url.url}></a
|
||||
>
|
||||
{/each}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.client {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 8px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
flex-grow: 1;
|
||||
|
||||
transition: transform 100ms linear;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
& .client-avatar {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
& .client-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > dl {
|
||||
margin: 0;
|
||||
|
||||
& > dt {
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .client-name {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& .client-url {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -10,6 +10,7 @@
|
||||
import { allowedImages } from '$lib/constants';
|
||||
|
||||
export let show: Writable<boolean>;
|
||||
export let url: string = '/account';
|
||||
|
||||
let cropper: Cropper;
|
||||
let image: HTMLImageElement;
|
||||
@ -75,7 +76,7 @@
|
||||
const data = new FormData();
|
||||
data.append('file', resultBlob);
|
||||
|
||||
await fetch(`/account?/avatar`, {
|
||||
await fetch(`${url}?/avatar`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
credentials: 'include',
|
||||
|
@ -3,6 +3,8 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(textarea),
|
||||
:global(select),
|
||||
:global(input) {
|
||||
background-color: var(--in-input-background);
|
||||
color: var(--in-input-color);
|
||||
@ -11,6 +13,7 @@
|
||||
&:not([type]),
|
||||
&[type='text'],
|
||||
&[type='password'],
|
||||
&[type='url'],
|
||||
&[type='email'] {
|
||||
padding: 8px;
|
||||
font-size: 1rem;
|
||||
@ -47,6 +50,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-control > :global(label):has(+ input[required])::after {
|
||||
|
@ -2,7 +2,7 @@
|
||||
"title": "Admin",
|
||||
"menu": {
|
||||
"users": "Users",
|
||||
"oauth2": "OAuth2 clients",
|
||||
"oauth2": "OAuth2 applications",
|
||||
"audit": "Audit logs"
|
||||
},
|
||||
"users": {
|
||||
@ -27,5 +27,86 @@
|
||||
"invalidDisplayName": "Invalid display name",
|
||||
"invalidEmail": "Invalid email address"
|
||||
}
|
||||
},
|
||||
"oauth2": {
|
||||
"title": "OAuth2 applications",
|
||||
"clientTitle": "Application name",
|
||||
"clientId": "Client ID",
|
||||
"clientSecret": "Client secret",
|
||||
"description": "Application description",
|
||||
"reveal": "Reveal secret",
|
||||
"regenerate": "Regenerate secret",
|
||||
"activated": "Activated",
|
||||
"verified": "Verified",
|
||||
"scopes": "Available scopes",
|
||||
"scopesHint": "The level of access to information you will be needing for this application.",
|
||||
"grants": "Available grant types",
|
||||
"grantsHint": "The OAuth2 authorization flows you will be using with this application.",
|
||||
"created": "Created at",
|
||||
"owner": "Created by",
|
||||
"ownerMe": "that's you!",
|
||||
"actions": "Actions",
|
||||
"delete": "Delete application permanently",
|
||||
"avatar": {
|
||||
"title": "Application icon",
|
||||
"remove": "Delete application icon",
|
||||
"change": "Change application icon"
|
||||
},
|
||||
"authorizations": "Authorized users",
|
||||
"authorizationsHint": "These users have authorized this application at least once. You may assign application privileges to each user individually.",
|
||||
"revoked": "This authorization has been revoked by the user",
|
||||
"noAuthorizations": "There are no authorizations on record for this application.",
|
||||
"privileges": {
|
||||
"title": "Application privileges",
|
||||
"name": "Privilege name",
|
||||
"nameHint": "An unique prefix \"{{prefix}}\" will be prepended automatically. English alphabet and ._-: characters only.",
|
||||
"add": "Add a new privilege",
|
||||
"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",
|
||||
"manage": "Manage user privileges",
|
||||
"new": "Add a new privilege"
|
||||
},
|
||||
"urls": {
|
||||
"title": "Application URLs",
|
||||
"new": "Add a new URL",
|
||||
"type": "URL type",
|
||||
"url": "URL",
|
||||
"types": {
|
||||
"website": "Website",
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Service",
|
||||
"redirect_uri": "Redirect URI"
|
||||
},
|
||||
"add": "Add URL"
|
||||
},
|
||||
"grantTexts": {
|
||||
"authorization_code": "Authorization code",
|
||||
"client_credentials": "Client credentials",
|
||||
"refresh_token": "Refresh token",
|
||||
"implicit": "Implicit token",
|
||||
"id_token": "ID token (OpenID Connect)"
|
||||
},
|
||||
"scopeTexts": {
|
||||
"picture": "Access profile picture URL",
|
||||
"profile": "Basic profile information",
|
||||
"email": "Access user email address",
|
||||
"privileges": "Access user privilege list",
|
||||
"management": "Manage your application",
|
||||
"account": "Change user account settings",
|
||||
"openid": "Get an ID token JWT (OpenID Connect)"
|
||||
},
|
||||
"errors": {
|
||||
"noRedirect": "At least one Redirect URI is required for you to be able to use this application!",
|
||||
"forbidden": "This action is forbidden for this user.",
|
||||
"invalidTitle": "Name must be between 3 and 32 characters long.",
|
||||
"invalidDescription": "Description must be at most 1000 characters long.",
|
||||
"deleteActivated": "Cannot delete an active application. Please deactivate it first.",
|
||||
"invalidUrlId": "Invalid URL ID for deletion.",
|
||||
"invalidUrlType": "Invalid URL type provided.",
|
||||
"invalidUrl": "Invalid URL provided.",
|
||||
"invalidPrivilegeId": "Invalid privilege ID for deletion.",
|
||||
"invalidPrivilege": "Invalid privilege provided.",
|
||||
"noFile": "Please upload a file first."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -15,5 +15,6 @@
|
||||
"false": "No"
|
||||
},
|
||||
"available": "Available",
|
||||
"current": "Current"
|
||||
"current": "Current",
|
||||
"remove": "Remove"
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import i18n, { type Config } from 'sveltekit-i18n';
|
||||
|
||||
interface Params {
|
||||
siteName: string;
|
||||
siteName?: string;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
const config: Config<Params> = {
|
||||
|
@ -1,5 +1,17 @@
|
||||
import { db, oauth2Client, oauth2ClientUrl, type OAuth2Client } from '$lib/server/drizzle';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import {
|
||||
db,
|
||||
oauth2Client,
|
||||
oauth2ClientManager,
|
||||
oauth2ClientUrl,
|
||||
privilege,
|
||||
user,
|
||||
type OAuth2Client,
|
||||
type OAuth2ClientUrl,
|
||||
type User
|
||||
} from '$lib/server/drizzle';
|
||||
import { Uploads } from '$lib/server/upload';
|
||||
import type { PaginationMeta } from '$lib/types';
|
||||
import { and, count, eq, ilike, like, or } from 'drizzle-orm';
|
||||
|
||||
export enum OAuth2ClientURLType {
|
||||
REDIRECT_URI = 'redirect_uri',
|
||||
@ -8,17 +20,25 @@ export enum OAuth2ClientURLType {
|
||||
WEBSITE = 'website'
|
||||
}
|
||||
|
||||
export interface OAuth2ClientAdminListItem
|
||||
extends PartialK<OAuth2Client, 'client_secret' | 'ownerId'> {
|
||||
isOwner?: boolean;
|
||||
ownerInfo?: { uuid: string; name: string };
|
||||
urls: Omit<OAuth2ClientUrl, 'created_at' | 'updated_at' | 'clientId'>[];
|
||||
}
|
||||
|
||||
export class OAuth2Clients {
|
||||
public static availableGrantTypes = [
|
||||
'authorization_code',
|
||||
'client_credentials',
|
||||
'refresh_token',
|
||||
'id_token',
|
||||
'implicit'
|
||||
];
|
||||
|
||||
public static availableScopes = [
|
||||
'picture',
|
||||
'profile',
|
||||
'picture',
|
||||
'email',
|
||||
'privileges',
|
||||
'management',
|
||||
@ -26,9 +46,26 @@ export class OAuth2Clients {
|
||||
'openid'
|
||||
];
|
||||
|
||||
public static userSetScopes = [
|
||||
'profile',
|
||||
'picture',
|
||||
'email',
|
||||
'privileges',
|
||||
'management',
|
||||
'openid'
|
||||
];
|
||||
public static userSetGrants = [
|
||||
'authorization_code',
|
||||
'client_credentials',
|
||||
'refresh_token',
|
||||
'id_token'
|
||||
];
|
||||
|
||||
public static describedScopes = ['email', 'picture', 'account'];
|
||||
public static alwaysPresentScopes = ['profile'];
|
||||
|
||||
public static availableUrlTypes: OAuth2ClientURLType[] = Object.values(OAuth2ClientURLType);
|
||||
|
||||
static async fetchById(id: string | number) {
|
||||
const [client] = await db
|
||||
.select()
|
||||
@ -104,6 +141,145 @@ export class OAuth2Clients {
|
||||
return scope.join(' ');
|
||||
}
|
||||
|
||||
static async getClientByAdminUser(
|
||||
subject: User,
|
||||
filters?: {
|
||||
clientId?: string;
|
||||
filter?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
omitSecret?: boolean;
|
||||
listAll?: boolean;
|
||||
}
|
||||
) {
|
||||
const filterText = `%${filters?.filter}%`;
|
||||
const limit = filters?.limit || 20;
|
||||
const allowedClients = db
|
||||
.select({ id: oauth2Client.id })
|
||||
.from(oauth2Client)
|
||||
.leftJoin(oauth2ClientManager, eq(oauth2ClientManager.clientId, oauth2Client.id))
|
||||
.where(
|
||||
and(
|
||||
!filters?.listAll
|
||||
? or(eq(oauth2Client.ownerId, subject.id), eq(oauth2ClientManager.userId, subject.id))
|
||||
: undefined,
|
||||
filters?.clientId ? eq(oauth2Client.client_id, filters.clientId) : undefined,
|
||||
filters?.filter
|
||||
? or(ilike(oauth2Client.title, filterText), like(oauth2Client.client_id, filterText))
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
.groupBy(oauth2Client.id)
|
||||
.limit(limit)
|
||||
.offset(filters?.offset || 0)
|
||||
.as('allowedClients');
|
||||
|
||||
const [{ rowCount }] = await db
|
||||
.select({
|
||||
rowCount: count(oauth2Client.id).mapWith(Number)
|
||||
})
|
||||
.from(allowedClients)
|
||||
.innerJoin(oauth2Client, eq(allowedClients.id, oauth2Client.id));
|
||||
|
||||
const junkList = await db
|
||||
.select({
|
||||
o_auth2_client: oauth2Client,
|
||||
o_auth2_client_url: oauth2ClientUrl,
|
||||
user: user
|
||||
})
|
||||
.from(allowedClients)
|
||||
.innerJoin(oauth2Client, eq(allowedClients.id, oauth2Client.id))
|
||||
.leftJoin(oauth2ClientUrl, eq(oauth2ClientUrl.clientId, oauth2Client.id))
|
||||
.leftJoin(user, eq(oauth2Client.ownerId, user.id));
|
||||
|
||||
const list = junkList.reduce<OAuth2ClientAdminListItem[]>((accum, dbo) => {
|
||||
let client = accum.find(({ id }) => id === dbo.o_auth2_client.id);
|
||||
if (!client) {
|
||||
client = {
|
||||
...dbo.o_auth2_client,
|
||||
isOwner: dbo.o_auth2_client.ownerId === subject.id,
|
||||
urls: []
|
||||
};
|
||||
accum.push(client);
|
||||
}
|
||||
|
||||
if (dbo.o_auth2_client_url) {
|
||||
if (!client.urls.some(({ id }) => dbo.o_auth2_client_url?.id === id)) {
|
||||
client.urls.push({
|
||||
...dbo.o_auth2_client_url,
|
||||
clientId: undefined,
|
||||
created_at: undefined,
|
||||
updated_at: undefined
|
||||
} as OAuth2ClientAdminListItem['urls'][0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (dbo.user) {
|
||||
client.ownerInfo = {
|
||||
uuid: dbo.user.uuid,
|
||||
name: dbo.user.display_name
|
||||
};
|
||||
}
|
||||
|
||||
if (filters?.omitSecret) {
|
||||
delete client.client_secret;
|
||||
}
|
||||
|
||||
return accum;
|
||||
}, []);
|
||||
|
||||
const meta: PaginationMeta = {
|
||||
rowCount,
|
||||
pageSize: limit,
|
||||
pageCount: Math.ceil(rowCount / limit)
|
||||
};
|
||||
|
||||
return {
|
||||
list,
|
||||
meta
|
||||
};
|
||||
}
|
||||
|
||||
static async deleteClient(client: OAuth2Client) {
|
||||
if (client.pictureId) {
|
||||
await Uploads.removeClientAvatar(client);
|
||||
}
|
||||
await db.delete(privilege).where(eq(privilege.clientId, client.id));
|
||||
await db.delete(oauth2Client).where(eq(oauth2Client.id, client.id));
|
||||
}
|
||||
|
||||
static async deleteUrl(client: OAuth2Client, urlId: number) {
|
||||
await db
|
||||
.delete(oauth2ClientUrl)
|
||||
.where(and(eq(oauth2ClientUrl.clientId, client.id), eq(oauth2ClientUrl.id, urlId)));
|
||||
}
|
||||
|
||||
static async addUrl(client: OAuth2Client, type: OAuth2ClientURLType, url: string) {
|
||||
await db.insert(oauth2ClientUrl).values({
|
||||
type,
|
||||
url,
|
||||
clientId: client.id
|
||||
});
|
||||
}
|
||||
|
||||
static async deletePrivilege(client: OAuth2Client, privilegeId: number) {
|
||||
await db
|
||||
.delete(privilege)
|
||||
.where(and(eq(privilege.clientId, client.id), eq(privilege.id, privilegeId)));
|
||||
}
|
||||
|
||||
static async addPrivilege(client: OAuth2Client, name: string) {
|
||||
const realName = `${client.client_id.split('-')[0]}:${name}`;
|
||||
await db.insert(privilege).values({
|
||||
name: realName,
|
||||
clientId: client.id
|
||||
});
|
||||
}
|
||||
|
||||
static async update(client: OAuth2Client, body: Partial<OAuth2Client>) {
|
||||
await db.update(oauth2Client).set(body).where(eq(oauth2Client.id, client.id));
|
||||
}
|
||||
|
||||
static async authorizeClientInfo(client: OAuth2Client, scope: string[]) {
|
||||
const links = await OAuth2Clients.getClientUrls(client);
|
||||
const filteredLinks = links
|
||||
@ -127,8 +303,6 @@ export class OAuth2Clients {
|
||||
allowedScopes.push('management');
|
||||
}
|
||||
|
||||
// TODO: client picture
|
||||
|
||||
return {
|
||||
links: filteredLinks,
|
||||
client_id: client.client_id,
|
||||
|
@ -1,16 +1,37 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db, upload, user, type Upload, type User } from './drizzle';
|
||||
import {
|
||||
db,
|
||||
oauth2Client,
|
||||
upload,
|
||||
user,
|
||||
type OAuth2Client,
|
||||
type Upload,
|
||||
type User
|
||||
} from './drizzle';
|
||||
import { Users } from './users';
|
||||
import { readFile, unlink, writeFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import * as mime from 'mime-types';
|
||||
import { OAuth2Clients } from './oauth2';
|
||||
|
||||
const fallbackImage = await readFile(join('static', 'avatar.png'));
|
||||
const userFallbackImage = await readFile(join('static', 'avatar.png'));
|
||||
const clientFallbackImage = await readFile(join('static', 'application.png'));
|
||||
|
||||
export class Uploads {
|
||||
static fallbackImage = fallbackImage;
|
||||
static userFallbackImage = userFallbackImage;
|
||||
static clientFallbackImage = clientFallbackImage;
|
||||
static uploads = join('uploads');
|
||||
|
||||
static async removeUpload(subject: Upload) {
|
||||
try {
|
||||
unlink(join(Uploads.uploads, subject.file));
|
||||
} catch {
|
||||
// ignore unlink error
|
||||
}
|
||||
|
||||
await db.delete(upload).where(eq(upload.id, subject.id));
|
||||
}
|
||||
|
||||
static async getAvatarByUuid(
|
||||
uuid: string
|
||||
): Promise<{ file: string; mimetype: string } | undefined> {
|
||||
@ -26,14 +47,19 @@ export class Uploads {
|
||||
return picture;
|
||||
}
|
||||
|
||||
static async removeUpload(subject: Upload) {
|
||||
try {
|
||||
unlink(join(Uploads.uploads, subject.file));
|
||||
} catch {
|
||||
// ignore unlink error
|
||||
static async getClientAvatarById(
|
||||
id: string
|
||||
): Promise<{ file: string; mimetype: string } | undefined> {
|
||||
const client = await OAuth2Clients.fetchById(id);
|
||||
if (!client?.pictureId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
await db.delete(upload).where(eq(upload.id, subject.id));
|
||||
const [picture] = await db
|
||||
.select({ mimetype: upload.mimetype, file: upload.file })
|
||||
.from(upload)
|
||||
.where(eq(upload.id, client.pictureId));
|
||||
return picture;
|
||||
}
|
||||
|
||||
static async removeAvatar(subject: User) {
|
||||
@ -47,6 +73,17 @@ export class Uploads {
|
||||
await db.update(user).set({ pictureId: null }).where(eq(user.id, subject.id));
|
||||
}
|
||||
|
||||
static async removeClientAvatar(client: OAuth2Client) {
|
||||
if (!client.pictureId) return;
|
||||
|
||||
const [fileinfo] = await db.select().from(upload).where(eq(upload.id, client.pictureId));
|
||||
if (fileinfo) {
|
||||
await Uploads.removeUpload(fileinfo);
|
||||
}
|
||||
|
||||
await db.update(oauth2Client).set({ pictureId: null }).where(eq(oauth2Client.id, client.id));
|
||||
}
|
||||
|
||||
static async saveAvatar(subject: User, file: File) {
|
||||
const ext = mime.extension(file.type);
|
||||
const newName = `user-${subject.uuid.split('-')[0]}-${Math.floor(Date.now() / 1000)}.${ext}`;
|
||||
@ -64,4 +101,25 @@ export class Uploads {
|
||||
});
|
||||
await db.update(user).set({ pictureId: retval.insertId }).where(eq(user.id, subject.id));
|
||||
}
|
||||
|
||||
static async saveClientAvatar(client: OAuth2Client, uploader: User, file: File) {
|
||||
const ext = mime.extension(file.type);
|
||||
const newName = `client-${client.client_id.substring(0, 8)}-${Math.floor(Date.now() / 1000)}.${ext}`;
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
// Write to filesystem
|
||||
await writeFile(join(Uploads.uploads, newName), Buffer.from(arrayBuffer));
|
||||
// Remove old
|
||||
await Uploads.removeClientAvatar(client);
|
||||
// Update DB
|
||||
const [retval] = await db.insert(upload).values({
|
||||
original_name: file.name,
|
||||
mimetype: file.type,
|
||||
file: newName,
|
||||
uploaderId: uploader.id
|
||||
});
|
||||
await db
|
||||
.update(oauth2Client)
|
||||
.set({ pictureId: retval.insertId })
|
||||
.where(eq(oauth2Client.id, client.id));
|
||||
}
|
||||
}
|
||||
|
@ -221,14 +221,19 @@ export class Users {
|
||||
.where(clientId ? eq(privilege.clientId, clientId) : isNull(privilege.clientId));
|
||||
}
|
||||
|
||||
static async getUserPrivileges(subject: User) {
|
||||
static async getUserPrivileges(subject: User, clientId?: number) {
|
||||
const list = await db
|
||||
.select({
|
||||
privilege: privilege.name
|
||||
})
|
||||
.from(privilege)
|
||||
.innerJoin(userPrivilegesPrivilege, eq(privilege.id, userPrivilegesPrivilege.privilegeId))
|
||||
.where(eq(userPrivilegesPrivilege.userId, subject.id));
|
||||
.where(
|
||||
and(
|
||||
eq(userPrivilegesPrivilege.userId, subject.id),
|
||||
clientId ? eq(privilege.clientId, clientId) : isNull(privilege.clientId)
|
||||
)
|
||||
);
|
||||
|
||||
return list.reduce<string[]>(
|
||||
(accum, { privilege }) => (!accum.includes(privilege) ? [...accum, privilege] : accum),
|
||||
|
@ -2,3 +2,4 @@ export const emailRegex =
|
||||
/^[-!#$%&'*+\\/0-9=?A-Z^_a-z`{|}~](\.?[-!#$%&'*+\\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
|
||||
export const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d\w\W]{8,}$/;
|
||||
export const usernameRegex = /^[a-zA-Z0-9_\-.]{3,26}$/;
|
||||
export const privilegeRegex = /^[a-zA-Z0-9_\-.:]{3,26}$/;
|
||||
|
@ -2,11 +2,10 @@ import { Uploads } from '$lib/server/upload.js';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function GET({ params }) {
|
||||
const uuid = params.slug;
|
||||
export async function GET({ params: { uuid } }) {
|
||||
const uploadFile = await Uploads.getAvatarByUuid(uuid);
|
||||
if (!uploadFile) {
|
||||
return new Response(Uploads.fallbackImage, {
|
||||
return new Response(Uploads.userFallbackImage, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png'
|
23
src/routes/api/avatar/client/[uuid]/+server.ts
Normal file
23
src/routes/api/avatar/client/[uuid]/+server.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Uploads } from '$lib/server/upload.js';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
|
||||
export async function GET({ params: { uuid } }) {
|
||||
const uploadFile = await Uploads.getClientAvatarById(uuid);
|
||||
if (!uploadFile) {
|
||||
return new Response(Uploads.clientFallbackImage, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'image/png'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const readUpload = await readFile(join(Uploads.uploads, uploadFile.file));
|
||||
return new Response(readUpload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': uploadFile.mimetype
|
||||
}
|
||||
});
|
||||
}
|
@ -9,6 +9,7 @@ import { Users } from '$lib/server/users/index.js';
|
||||
|
||||
export const GET = async ({ request, url, locals }) => {
|
||||
let user: User | undefined = undefined;
|
||||
let clientId: number | undefined = undefined;
|
||||
let tokenScopes: string[] | undefined = undefined;
|
||||
|
||||
if (locals.session?.data?.user) {
|
||||
@ -21,6 +22,7 @@ export const GET = async ({ request, url, locals }) => {
|
||||
if (token?.userId) {
|
||||
tokenScopes = OAuth2Clients.splitScope(token.scope || '');
|
||||
user = await Users.getById(token.userId);
|
||||
clientId = token.clientId || undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof OAuth2Error) {
|
||||
@ -58,7 +60,9 @@ export const GET = async ({ request, url, locals }) => {
|
||||
userData.picture = `${PUBLIC_URL}/api/avatar/${user.uuid}`;
|
||||
}
|
||||
|
||||
// TODO: privileges
|
||||
if (scopelessAccess || tokenScopes?.includes('privileges')) {
|
||||
userData.privileges = await Users.getUserPrivileges(user, clientId);
|
||||
}
|
||||
|
||||
return ApiUtils.json(userData);
|
||||
};
|
||||
|
@ -46,8 +46,7 @@
|
||||
{/if}
|
||||
<div class="graphic" aria-hidden="true"></div>
|
||||
<div class="card">
|
||||
<!-- TODO: client pictures -->
|
||||
<AvatarCard src={`${assets}/application.png`} alt={data.client.title}>
|
||||
<AvatarCard src={`/api/avatar/client/${data.client.client_id}`} alt={data.client.title}>
|
||||
<div class="card-inner">
|
||||
<span class="card-display-name">{data.client.title}</span>
|
||||
<span class="card-user-name">{data.client.description}</span>
|
||||
|
44
src/routes/ssoadmin/oauth2/+page.server.ts
Normal file
44
src/routes/ssoadmin/oauth2/+page.server.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { AdminUtils } from '$lib/server/admin-utils';
|
||||
import type { User } from '$lib/server/drizzle/schema.js';
|
||||
import { OAuth2Clients } from '$lib/server/oauth2/index.js';
|
||||
import { Users } from '$lib/server/users';
|
||||
import { hasPrivileges } from '$lib/utils.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export const load = async ({ parent, url }) => {
|
||||
const { user } = await parent();
|
||||
const currentUser = await Users.getBySession(user);
|
||||
AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2']]);
|
||||
|
||||
let limit = PAGE_SIZE;
|
||||
let page = 1;
|
||||
let filter: string | undefined = undefined;
|
||||
if (url.searchParams.has('page')) {
|
||||
page = Number(url.searchParams.get('page')) || 1;
|
||||
}
|
||||
|
||||
if (url.searchParams.has('pageSize')) {
|
||||
limit = Number(url.searchParams.get('pageSize')) || PAGE_SIZE;
|
||||
}
|
||||
|
||||
if (url.searchParams.has('filter')) {
|
||||
filter = url.searchParams.get('filter') as string;
|
||||
}
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
const fullPrivileges = hasPrivileges(user.privileges, ['admin:oauth2']);
|
||||
|
||||
const data = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
|
||||
filter,
|
||||
limit,
|
||||
offset,
|
||||
omitSecret: true,
|
||||
listAll: fullPrivileges
|
||||
});
|
||||
|
||||
return {
|
||||
fullPrivileges,
|
||||
...data
|
||||
};
|
||||
};
|
33
src/routes/ssoadmin/oauth2/+page.svelte
Normal file
33
src/routes/ssoadmin/oauth2/+page.svelte
Normal file
@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import Paginator from '$lib/components/Paginator.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { PageData } from './$types';
|
||||
import ClientCard from '$lib/components/admin/AdminClientCard.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<h1>{$t('admin.oauth2.title')}</h1>
|
||||
|
||||
<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>
|
||||
|
||||
<style>
|
||||
.client-link {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.client-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
</style>
|
402
src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts
Normal file
402
src/routes/ssoadmin/oauth2/[uuid]/+page.server.ts
Normal file
@ -0,0 +1,402 @@
|
||||
import { AdminUtils } from '$lib/server/admin-utils';
|
||||
import { Changesets } from '$lib/server/changesets.js';
|
||||
import { CryptoUtils } from '$lib/server/crypto-utils.js';
|
||||
import type { OAuth2Client, User } from '$lib/server/drizzle';
|
||||
import { OAuth2ClientURLType, OAuth2Clients } from '$lib/server/oauth2';
|
||||
import { Uploads } from '$lib/server/upload.js';
|
||||
import { Users } from '$lib/server/users';
|
||||
import { UsersAdmin } from '$lib/server/users/admin';
|
||||
import { hasPrivileges } from '$lib/utils';
|
||||
import { privilegeRegex } from '$lib/validators.js';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
|
||||
interface AddUrlRequest {
|
||||
type: OAuth2ClientURLType;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface UpdateRequest {
|
||||
title: string;
|
||||
description: string;
|
||||
activated?: string;
|
||||
verified?: string;
|
||||
}
|
||||
|
||||
interface AddPrivilegeRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
update: async ({ locals, request, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = 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 fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
|
||||
|
||||
const body = await request.formData();
|
||||
const { title, description, activated, verified } = Changesets.take<UpdateRequest>(
|
||||
['title', 'description', 'activated', 'verified'],
|
||||
body
|
||||
);
|
||||
|
||||
if (!!verified && !fullPrivileges) {
|
||||
return fail(403, { errors: ['forbidden'] });
|
||||
}
|
||||
|
||||
const actuallyVerified = fullPrivileges ? Number(!!verified) : undefined;
|
||||
const actuallyActivated = Number(!!activated);
|
||||
|
||||
if (title && (title.length < 3 || title.length > 32)) {
|
||||
return fail(403, { errors: ['invalidTitle'] });
|
||||
}
|
||||
|
||||
if (description && description.length > 1000) {
|
||||
return fail(403, { errors: ['invalidDescription'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.update(details as OAuth2Client, {
|
||||
title,
|
||||
description,
|
||||
verified: actuallyVerified,
|
||||
activated: actuallyActivated
|
||||
});
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
delete: async ({ locals, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = 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');
|
||||
}
|
||||
|
||||
if (details.activated === 1) {
|
||||
return fail(400, { errors: ['deleteActivated'] });
|
||||
}
|
||||
|
||||
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
|
||||
if (details.ownerId !== currentUser.id && !fullPrivileges) {
|
||||
return fail(403, { errors: ['forbidden'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.deleteClient(details as OAuth2Client);
|
||||
|
||||
return redirect(303, '/ssoadmin/oauth2');
|
||||
},
|
||||
regenerate: async ({ locals, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = 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 fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
|
||||
if (!fullPrivileges && !details.isOwner) {
|
||||
return fail(403, { errors: ['forbidden'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.update(details as OAuth2Client, {
|
||||
client_secret: CryptoUtils.generateSecret()
|
||||
});
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
removeUrl: async ({ locals, url, params: { uuid } }) => {
|
||||
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 id = Number(url.searchParams.get('id'));
|
||||
if (isNaN(id)) {
|
||||
return fail(400, { errors: ['invalidUrlId'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.deleteUrl(details as OAuth2Client, id);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
addUrl: async ({ locals, request, params: { uuid } }) => {
|
||||
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 body = await request.formData();
|
||||
const { type, url } = Changesets.take<AddUrlRequest>(['type', 'url'], body);
|
||||
if (!type || !OAuth2Clients.availableUrlTypes.includes(type)) {
|
||||
return fail(400, { errors: ['invalidUrlType'] });
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return fail(400, { errors: ['invalidUrl'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.addUrl(details as OAuth2Client, type, url);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
removePrivilege: async ({ locals, url, params: { uuid } }) => {
|
||||
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 id = Number(url.searchParams.get('id'));
|
||||
if (isNaN(id)) {
|
||||
return fail(400, { errors: ['invalidPrivilegeId'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.deletePrivilege(details as OAuth2Client, id);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
addPrivilege: async ({ locals, request, params: { uuid } }) => {
|
||||
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 body = await request.formData();
|
||||
const { name } = Changesets.take<AddPrivilegeRequest>(['name'], body);
|
||||
|
||||
if (!name || !privilegeRegex.test(name)) {
|
||||
return fail(400, { errors: ['invalidPrivilege'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.addPrivilege(details as OAuth2Client, name);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
grants: async ({ locals, request, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = 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 fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
|
||||
const allowedGrants = fullPrivileges
|
||||
? OAuth2Clients.availableGrantTypes
|
||||
: OAuth2Clients.userSetGrants;
|
||||
|
||||
const body = await request.formData();
|
||||
const values = Array.from(body.keys());
|
||||
values.unshift('authorization_code');
|
||||
const deduplicatedAllowedGrants = values.filter(
|
||||
(value, index, array) => allowedGrants.includes(value) && array.indexOf(value) === index
|
||||
);
|
||||
|
||||
await OAuth2Clients.update(details as OAuth2Client, {
|
||||
grants: deduplicatedAllowedGrants.join(' ')
|
||||
});
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
scopes: async ({ locals, request, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = 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 fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
|
||||
const allowedScopes = fullPrivileges
|
||||
? OAuth2Clients.availableScopes
|
||||
: OAuth2Clients.userSetScopes;
|
||||
|
||||
const body = await request.formData();
|
||||
const values = Array.from(body.keys());
|
||||
values.unshift('profile');
|
||||
const deduplicatedAllowedScopes = values.filter(
|
||||
(value, index, array) => allowedScopes.includes(value) && array.indexOf(value) === index
|
||||
);
|
||||
|
||||
await OAuth2Clients.update(details as OAuth2Client, {
|
||||
scope: OAuth2Clients.joinScope(deduplicatedAllowedScopes)
|
||||
});
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
avatar: async ({ request, locals, params: { uuid } }) => {
|
||||
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 formData = Object.fromEntries(await request.formData());
|
||||
if (!(formData.file as File)?.name || (formData.file as File).name === 'undefined') {
|
||||
return fail(400, {
|
||||
errors: ['noFile']
|
||||
});
|
||||
}
|
||||
|
||||
const { file } = formData as { file: File };
|
||||
|
||||
await Uploads.saveClientAvatar(details as OAuth2Client, currentUser, file);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
removeAvatar: async ({ locals, params: { uuid } }) => {
|
||||
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');
|
||||
}
|
||||
|
||||
await Uploads.removeClientAvatar(details as OAuth2Client);
|
||||
|
||||
return { errors: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export const load = async ({ params: { uuid }, parent }) => {
|
||||
const { user } = await parent();
|
||||
const currentUser = await Users.getBySession(user);
|
||||
AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2']]);
|
||||
|
||||
const fullPrivileges = hasPrivileges(user.privileges, ['admin: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 privileges = await Users.getAvailablePrivileges(details.id);
|
||||
|
||||
return {
|
||||
availableUrls: OAuth2Clients.availableUrlTypes,
|
||||
availablePrivileges: privileges,
|
||||
availableGrants: fullPrivileges
|
||||
? OAuth2Clients.availableGrantTypes
|
||||
: OAuth2Clients.userSetGrants,
|
||||
availableScopes: fullPrivileges ? OAuth2Clients.availableScopes : OAuth2Clients.userSetScopes,
|
||||
fullPrivileges,
|
||||
details
|
||||
};
|
||||
};
|
333
src/routes/ssoadmin/oauth2/[uuid]/+page.svelte
Normal file
333
src/routes/ssoadmin/oauth2/[uuid]/+page.svelte
Normal file
@ -0,0 +1,333 @@
|
||||
<script lang="ts">
|
||||
import Alert from '$lib/components/Alert.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
|
||||
import AvatarModal from '$lib/components/avatar/AvatarModal.svelte';
|
||||
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||
import SplitView from '$lib/components/container/SplitView.svelte';
|
||||
import ButtonRow from '$lib/components/container/ButtonRow.svelte';
|
||||
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 { t } from '$lib/i18n';
|
||||
import { page } from '$app/stores';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { writable } from 'svelte/store';
|
||||
import FormErrors from '$lib/components/form/FormErrors.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
|
||||
let showAvatarModal = writable(false);
|
||||
let secret = false;
|
||||
let addingUrl = false;
|
||||
let addingPrivilege = false;
|
||||
|
||||
$: noRedirects = !data.details.urls.some(({ type }) => type === 'redirect_uri');
|
||||
$: availableUrls = data.availableUrls.filter((type) => {
|
||||
// Can have up to three redirect URIs, only one of other types
|
||||
const countOfType = data.details.urls.filter(({ type: subType }) => type === subType).length;
|
||||
if (type === 'redirect_uri') {
|
||||
return countOfType < 3;
|
||||
}
|
||||
return !countOfType;
|
||||
});
|
||||
$: splitScopes = data.details.scope?.split(' ') || [];
|
||||
$: splitGrants = data.details.grants?.split(' ') || [];
|
||||
$: uuidPrefix = data.details.client_id.split('-')[0] + ':';
|
||||
</script>
|
||||
|
||||
<h1>{$t('admin.oauth2.title')} / {data.details.title}</h1>
|
||||
|
||||
<ColumnView>
|
||||
<SplitView>
|
||||
<ColumnView>
|
||||
<AvatarCard src={`/api/avatar/client/${data.details.client_id}?t=${data.renderrt}`}>
|
||||
<ColumnView>
|
||||
<div>
|
||||
<Button variant="primary" on:click={() => ($showAvatarModal = true)}
|
||||
>{$t('admin.oauth2.avatar.change')}</Button
|
||||
>
|
||||
</div>
|
||||
{#if data.details.pictureId}
|
||||
<form action="?/removeAvatar" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.oauth2.avatar.remove')}</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</ColumnView>
|
||||
</AvatarCard>
|
||||
|
||||
<form action="?/update" method="POST">
|
||||
<FormWrapper>
|
||||
<FormErrors errors={form?.errors || []} prefix="admin.oauth2.errors" />
|
||||
<FormSection>
|
||||
<FormControl>
|
||||
<label for="client-title">{$t('admin.oauth2.clientTitle')}</label>
|
||||
<input name="title" id="client-title" value={data.details.title} />
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<label for="client-id">{$t('admin.oauth2.clientId')}</label>
|
||||
<input readonly id="client-id" value={data.details.client_id} />
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<label for="client-secret">{$t('admin.oauth2.clientSecret')}</label>
|
||||
{#if secret}
|
||||
<input readonly id="client-secret" value={data.details.client_secret} />
|
||||
{:else}
|
||||
<Button on:click={() => (secret = true)}>{$t('admin.oauth2.reveal')}</Button>
|
||||
{/if}
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<label for="client-description">{$t('admin.oauth2.description')}</label>
|
||||
<textarea
|
||||
name="description"
|
||||
id="client-description"
|
||||
value={data.details.description}
|
||||
rows="3"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<label for="client-activated">{$t('admin.oauth2.activated')}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="activated"
|
||||
id="client-activated"
|
||||
checked={Boolean(data.details.activated)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
{#if data.fullPrivileges}
|
||||
<FormControl>
|
||||
<label for="client-verified">{$t('admin.oauth2.verified')}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="verified"
|
||||
id="client-verified"
|
||||
checked={Boolean(data.details.verified)}
|
||||
/>
|
||||
</FormControl>
|
||||
{/if}
|
||||
</FormSection>
|
||||
|
||||
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
</ColumnView>
|
||||
|
||||
<ColumnView>
|
||||
<h2>{$t('admin.oauth2.actions')}</h2>
|
||||
{#if data.fullPrivileges || data.details.isOwner}
|
||||
{#if !data.details.activated}
|
||||
<form action="?/delete" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.oauth2.delete')}</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<form action="?/regenerate" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.oauth2.regenerate')}</Button>
|
||||
</form>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<h2>{$t('admin.oauth2.urls.title')}</h2>
|
||||
{#if noRedirects}
|
||||
<Alert type="error">
|
||||
{$t('admin.oauth2.errors.noRedirect')}
|
||||
</Alert>
|
||||
{/if}
|
||||
<div class="urls addremove">
|
||||
{#each data.details.urls as url}
|
||||
<div class="url addremove-item">
|
||||
<div>
|
||||
<div class="url-type">{$t(`admin.oauth2.urls.types.${url.type}`)}</div>
|
||||
<a class="url-url" href={url.url} target="_blank" rel="nofollow noreferrer"
|
||||
>{url.url}</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<form action="?/removeUrl&id={url.id}" method="POST">
|
||||
<Button type="submit" variant="link">{$t('common.remove')}</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !addingUrl && availableUrls.length}
|
||||
<div>
|
||||
<Button variant="link" on:click={() => (addingUrl = true)}
|
||||
>+ {$t('admin.oauth2.urls.add')}</Button
|
||||
>
|
||||
</div>
|
||||
{:else if addingUrl}
|
||||
<form action="?/addUrl" method="POST">
|
||||
<FormWrapper>
|
||||
<FormSection title={$t('admin.oauth2.urls.new')}>
|
||||
<FormControl>
|
||||
<label for="newurl-type">{$t('admin.oauth2.urls.type')}</label>
|
||||
<select name="type" id="newurl-type">
|
||||
{#each availableUrls as urlType}
|
||||
<option value={urlType}>{$t(`admin.oauth2.urls.types.${urlType}`)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<label for="newurl-url">{$t('admin.oauth2.urls.url')}</label>
|
||||
<input name="url" type="url" id="newurl-url" />
|
||||
</FormControl>
|
||||
</FormSection>
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
|
||||
<Button variant="link" on:click={() => (addingUrl = false)}
|
||||
>{$t('common.cancel')}</Button
|
||||
>
|
||||
</ButtonRow>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<h2>{$t('admin.oauth2.privileges.title')}</h2>
|
||||
<p>{$t('admin.oauth2.privileges.addHint')}</p>
|
||||
<div class="privileges addremove">
|
||||
{#each data.availablePrivileges as privilege}
|
||||
{@const [idPart, ...rest] = privilege.name.split(':')}
|
||||
<div class="privilege addremove-item">
|
||||
<span class="privilege-name"
|
||||
><span class="privilege-id">{idPart}:</span>{rest.join(':')}</span
|
||||
>
|
||||
<form action="?/removePrivilege&id={privilege.id}" method="POST">
|
||||
<Button type="submit" variant="link">{$t('common.remove')}</Button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if !addingPrivilege}
|
||||
<div>
|
||||
<Button variant="link" on:click={() => (addingPrivilege = true)}
|
||||
>+ {$t('admin.oauth2.privileges.add')}</Button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<form action="?/addPrivilege" method="POST">
|
||||
<FormWrapper>
|
||||
<FormSection title={$t('admin.oauth2.privileges.new')}>
|
||||
<FormControl>
|
||||
<label for="newpriv-name">{$t('admin.oauth2.privileges.name')}</label>
|
||||
|
||||
<input name="name" id="newpriv-name" />
|
||||
<span>{$t('admin.oauth2.privileges.nameHint', { prefix: uuidPrefix })}</span>
|
||||
</FormControl>
|
||||
</FormSection>
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
|
||||
<Button variant="link" on:click={() => (addingPrivilege = false)}
|
||||
>{$t('common.cancel')}</Button
|
||||
>
|
||||
</ButtonRow>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
{/if}
|
||||
</ColumnView>
|
||||
</SplitView>
|
||||
|
||||
<h2>{$t('admin.oauth2.grants')}</h2>
|
||||
<p>{$t('admin.oauth2.grantsHint')}</p>
|
||||
|
||||
<form action="?/grants" method="POST">
|
||||
<div class="scope-cloud">
|
||||
{#each data.availableGrants as grant}
|
||||
<FormControl>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`grant-${grant}`}
|
||||
name={grant}
|
||||
disabled={grant === 'authorization_code'}
|
||||
checked={grant === 'authorization_code' ? true : splitGrants.includes(grant)}
|
||||
/>
|
||||
<label for={`grant-${grant}`}
|
||||
><code>{grant}</code> - {$t(`admin.oauth2.grantTexts.${grant}`)}</label
|
||||
>
|
||||
</FormControl>
|
||||
{/each}
|
||||
</div>
|
||||
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
|
||||
</form>
|
||||
|
||||
<h2>{$t('admin.oauth2.scopes')}</h2>
|
||||
<p>{$t('admin.oauth2.scopesHint')}</p>
|
||||
|
||||
<form action="?/scopes" method="POST">
|
||||
<div class="scope-cloud">
|
||||
{#each data.availableScopes as scope}
|
||||
<FormControl>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`scope-${scope}`}
|
||||
name={scope}
|
||||
disabled={scope === 'profile'}
|
||||
checked={scope === 'profile' ? true : splitScopes.includes(scope)}
|
||||
/>
|
||||
<label for={`scope-${scope}`}
|
||||
><code>{scope}</code> - {$t(`admin.oauth2.scopeTexts.${scope}`)}</label
|
||||
>
|
||||
</FormControl>
|
||||
{/each}
|
||||
</div>
|
||||
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
|
||||
</form>
|
||||
|
||||
<h2>{$t('admin.oauth2.authorizations')}</h2>
|
||||
<p>{$t('admin.oauth2.authorizationsHint')}</p>
|
||||
|
||||
<b>{$t('admin.oauth2.noAuthorizations')}</b>
|
||||
</ColumnView>
|
||||
|
||||
<AvatarModal show={showAvatarModal} url={$page.url.pathname} />
|
||||
|
||||
<style>
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.addremove {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
& .addremove-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #fff;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
& .privilege-id {
|
||||
color: #646464;
|
||||
}
|
||||
|
||||
& .url-type {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& .url-url {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.scope-cloud {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
@ -18,7 +18,7 @@ interface UpdateRequest {
|
||||
|
||||
export const actions = {
|
||||
removeOtp: async () => {},
|
||||
removeAvatar: async ({ locals, params: { slug: uuid } }) => {
|
||||
removeAvatar: async ({ locals, params: { uuid } }) => {
|
||||
await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']);
|
||||
|
||||
const targetUser = await Users.getByUuid(uuid, false);
|
||||
@ -30,7 +30,7 @@ export const actions = {
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
deleteInfo: async ({ locals, params: { slug: uuid } }) => {
|
||||
deleteInfo: async ({ locals, params: { uuid } }) => {
|
||||
await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']);
|
||||
|
||||
const targetUser = await Users.getByUuid(uuid, false);
|
||||
@ -56,7 +56,7 @@ export const actions = {
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
email: async ({ locals, params: { slug: uuid }, url }) => {
|
||||
email: async ({ locals, params: { uuid }, url }) => {
|
||||
await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']);
|
||||
|
||||
const type = url.searchParams.get('type') as 'password' | 'activate';
|
||||
@ -84,7 +84,7 @@ export const actions = {
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
update: async ({ locals, params: { slug: uuid }, request }) => {
|
||||
update: async ({ locals, params: { uuid }, request }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
|
||||
'admin',
|
||||
'admin:user'
|
||||
@ -136,11 +136,10 @@ export const actions = {
|
||||
}
|
||||
};
|
||||
|
||||
export const load = async ({ parent, params }) => {
|
||||
export const load = async ({ parent, params: { uuid } }) => {
|
||||
const { user } = await parent();
|
||||
AdminUtils.checkPrivileges(user, ['admin:user']);
|
||||
|
||||
const uuid = params.slug;
|
||||
const userInfo = await UsersAdmin.getUserDetails(uuid);
|
||||
if (!userInfo) {
|
||||
error(404, 'User not found');
|
Loading…
Reference in New Issue
Block a user