Retheme, organize, comment

This commit is contained in:
Evert Prants 2024-06-04 20:10:26 +03:00
parent dc9be2016b
commit 77e2b49aa6
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
38 changed files with 845 additions and 537 deletions

View File

@ -1,34 +1,11 @@
@import url('./colors.css');
*, *,
*::before, *::before,
*::after { *::after {
box-sizing: border-box; box-sizing: border-box;
} }
:root {
--in-text-color: #fff;
--in-link-color: #fff;
--in-outline-color: #00aaff;
--in-normalized-background: #000;
--in-input-background: #fff;
--in-input-background-disabled: #c2c2c2;
--in-input-required-color: #ff0000;
--in-input-color: #000;
--in-input-color-disabled: #414141;
--in-input-border-color: #ddd;
--in-input-border-color-disabled: #a0a0a0;
--in-input-border-color-invalid: #ff0000;
--in-alert-color: #006597;
--in-error-color: #b52e2e;
--in-success-color: #1e7f27;
--in-modal-background: #fff;
--in-modal-backdrop: rgba(0, 0, 0, 0.3);
--in-modal-divider-color: #ddd;
--in-focus-outline: 3px solid var(--in-outline-color);
}
:root { :root {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
'Open Sans', 'Helvetica Neue', sans-serif; 'Open Sans', 'Helvetica Neue', sans-serif;
@ -40,11 +17,12 @@ body {
margin: 0; margin: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
overscroll-behavior: none;
} }
body { body {
background-color: var(--in-normalized-background); background-color: var(--in-normalized-background);
background-image: url('/background.jpg'); background-image: var(--in-background-image);
background-attachment: fixed; background-attachment: fixed;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
@ -72,7 +50,7 @@ a {
a[target='_blank']::after { a[target='_blank']::after {
content: ''; content: '';
background-image: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27%23ffffff%27 d=%27M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z%27 /%3E%3C/svg%3E'); background-image: var(--in-external-link-icon);
width: 0.95rem; width: 0.95rem;
height: 0.95rem; height: 0.95rem;
display: inline-block; display: inline-block;

122
src/colors.css Normal file
View File

@ -0,0 +1,122 @@
/**************/
/* Light Mode */
/**************/
:root {
--in-background-image: none;
--in-normalized-background: #cceaff;
--in-container-background: #f1faff;
--in-text-color: #000000;
--in-link-color: #000000;
--in-external-link-icon: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27000000%27 d=%27M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z%27 /%3E%3C/svg%3E');
--in-outline-color: #00aaff;
--in-input-background: #fff;
--in-input-required-color: #ff0000;
--in-input-color: #000;
--in-input-border: 1px solid #cfcfcf;
--in-input-border-color-invalid: #ff0000;
--in-input-color-disabled: #535353;
--in-input-background-disabled: #e9e9e9;
--in-input-border-color-disabled: #b9b9b9;
--in-alert-color: #86d7ff;
--in-error-color: #ff8888;
--in-success-color: #6ade6d;
--in-modal-background: #fff;
--in-modal-backdrop: rgba(0, 0, 0, 0.3);
--in-modal-divider-color: #cfcfcf;
--in-modal-text-color: #000;
--in-button-primary-background: #fff;
--in-button-primary-hover-background: #ececec;
--in-button-primary-border: 1px solid #cfcfcf;
--in-button-primary-color: #000;
--in-button-primary-disabled-background: #d5d5d5;
--in-button-primary-disabled-border: 1px solid #a5a5a5;
--in-button-primary-disabled-color: #626262;
--in-focus-outline: 3px solid var(--in-outline-color);
}
:root {
--ina-background-color: #ececec;
--ina-header-color: linear-gradient(180deg, #00aaff 0%, #0092db 100%);
--ina-header-link-color: #ffffff;
--ina-header-tag-color: #006eb8;
--ina-header-tag-text-color: #ffffff;
--ina-sidebar-color: #dddddd;
--ina-sidebar-border-color: #b4b4b4;
--ina-sidebar-link-text-color: #000;
--ina-sidebar-link-active-color: #c7c7c7;
--ina-sidebar-link-hover-color: #e4e4e4;
--ina-card-background: #fff;
--ina-card-text-muted: #646464;
--ina-card-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
}
/*************/
/* Dark Mode */
/*************/
html[theme-base='dark'] {
--in-background-image: url('/background.jpg');
--in-normalized-background: #000;
--in-container-background: transparent;
--in-text-color: #fff;
--in-link-color: #fff;
--in-external-link-icon: url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 style=%27width:24px;height:24px%27 viewBox=%270 0 24 24%27%3E%3Cpath fill=%27%23ffffff%27 d=%27M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z%27 /%3E%3C/svg%3E');
--in-input-background: #2e2e2e;
--in-input-background-disabled: #101010;
--in-input-required-color: #ff0000;
--in-input-color: #fff;
--in-input-color-disabled: #8f8f8f;
--in-input-border-color: #242424;
--in-input-border-color-disabled: #242424;
--in-input-border-color-invalid: #ff0000;
--in-alert-color: #006597;
--in-error-color: #b52e2e;
--in-success-color: #1e7f27;
--in-modal-background: #232323;
--in-modal-divider-color: #181818;
--in-modal-text-color: #fff;
--in-button-primary-background: #2e2e2e;
--in-button-primary-hover-background: #4b4b4b;
--in-button-primary-border: 2px solid #595959;
--in-button-primary-color: #fff;
--in-button-primary-disabled-background: #101010;
--in-button-primary-disabled-border: 2px solid #242424;
--in-button-primary-disabled-color: #8f8f8f;
}
html[theme-base='dark'] {
--ina-background-color: #232323;
--ina-header-color: linear-gradient(180deg, #00aaff 0%, #0092db 100%);
--ina-header-link-color: #ffffff;
--ina-header-tag-color: #006eb8;
--ina-header-tag-text-color: #ffffff;
--ina-sidebar-color: #191919;
--ina-sidebar-border-color: #0b0b0b;
--ina-sidebar-link-text-color: #ffffff;
--ina-sidebar-link-active-color: #2c2c2c;
--ina-sidebar-link-hover-color: #242424;
--ina-card-background: #1c1c1c;
--ina-card-text-muted: #a1a1a1;
--ina-card-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
}

View File

@ -1,5 +1,6 @@
import { AUTO_MIGRATE, SESSION_SECRET } from '$env/static/private'; import { AUTO_MIGRATE, SESSION_SECRET } from '$env/static/private';
import { db } from '$lib/server/drizzle'; import { db } from '$lib/server/drizzle';
import { runSeeds } from '$lib/server/drizzle/seeds';
import { migrate } from 'drizzle-orm/mysql2/migrator'; import { migrate } from 'drizzle-orm/mysql2/migrator';
import { handleSession } from 'svelte-kit-cookie-session'; import { handleSession } from 'svelte-kit-cookie-session';
@ -7,6 +8,8 @@ if (AUTO_MIGRATE === 'true') {
await migrate(db, { migrationsFolder: './migrations' }); await migrate(db, { migrationsFolder: './migrations' });
} }
await runSeeds();
export const handle = handleSession({ export const handle = handleSession({
secret: SESSION_SECRET secret: SESSION_SECRET
}); });

View File

@ -0,0 +1,18 @@
<script lang="ts">
import type { ComponentProps } from 'svelte';
import Button from './Button.svelte';
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
export let action: string;
export let variant: ComponentProps<Button>['variant'] = 'link';
export let enhanced = false;
export let enhanceFn: SubmitFunction | undefined = undefined;
$: enhancer = enhanced ? enhance : () => {};
</script>
<form {action} method="POST" use:enhancer={enhanceFn}>
<slot name="form" />
<Button {variant} type="submit"><slot /></Button>
</form>

View File

@ -18,7 +18,7 @@
background-color: var(--in-alert-color); background-color: var(--in-alert-color);
width: 100%; width: 100%;
border-radius: 8px; border-radius: 8px;
padding: 24px 28px; padding: 16px 18px;
font-size: 1.15rem; font-size: 1.15rem;
& p { & p {

View File

@ -17,7 +17,7 @@
border: 0; border: 0;
padding: 0; padding: 0;
background: transparent; background: transparent;
color: var(--in-text-color); color: var(--in-link-color);
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
@ -32,11 +32,21 @@
.btn-default, .btn-default,
.btn-primary { .btn-primary {
background-color: #fff; background-color: var(--in-button-primary-background);
color: #000; color: var(--in-button-primary-color);
border: 2px solid #ddd; border: var(--in-button-primary-border);
padding: 6px 12px; padding: 6px 12px;
border-radius: 4px; border-radius: 4px;
font-weight: 700; font-weight: 700;
&:hover {
background-color: var(--in-button-primary-hover-background);
}
&[disabled] {
background-color: var(--in-button-primary-disabled-background);
color: var(--in-button-primary-disabled-color);
cursor: default;
}
} }
</style> </style>

View File

@ -1,8 +1,6 @@
<script> <script>
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import Button from './Button.svelte'; import ActionButton from './ActionButton.svelte';
</script> </script>
<form action="/account?/logout" method="POST"> <ActionButton action="/account?/logout">{$t('account.logout')}</ActionButton>
<Button type="submit" variant="link">{$t('account.logout')}</Button>
</form>

View File

@ -2,6 +2,7 @@
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
export let showModal: boolean; export let showModal: boolean;
export let dismissable = true;
let dialog: HTMLDialogElement; let dialog: HTMLDialogElement;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -17,7 +18,7 @@
showModal = false; showModal = false;
dispatch('close'); dispatch('close');
}} }}
on:click|self={() => dialog.close()} on:click|self={() => dismissable && dialog.close()}
> >
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:click|stopPropagation> <div on:click|stopPropagation>
@ -43,6 +44,7 @@
border-radius: 0.2em; border-radius: 0.2em;
border: none; border: none;
padding: 0; padding: 0;
color: var(--in-modal-text-color);
background-color: var(--in-modal-background); background-color: var(--in-modal-background);
} }

View File

@ -16,7 +16,7 @@
href={`?page=${pageNum - 1}`} href={`?page=${pageNum - 1}`}
tabindex={firstPage ? -1 : 0} tabindex={firstPage ? -1 : 0}
aria-label={$t('common.previous')} aria-label={$t('common.previous')}
aria-disabled={firstPage}>&lt;&lt;</a aria-disabled={firstPage}>&lt;</a
> >
{#each pageButtons as buttonNumber} {#each pageButtons as buttonNumber}
@ -35,7 +35,7 @@
tabindex={lastPage ? -1 : 0} tabindex={lastPage ? -1 : 0}
href={`?page=${pageNum + 1}`} href={`?page=${pageNum + 1}`}
aria-label={$t('common.next')} aria-label={$t('common.next')}
aria-disabled={lastPage}>&gt;&gt;</a aria-disabled={lastPage}>&gt;</a
> >
</nav> </nav>
@ -49,8 +49,8 @@
.page-button { .page-button {
--in-page-button-size: 28px; --in-page-button-size: 28px;
display: block; display: block;
background-color: #fff; background-color: var(--in-button-primary-background);
color: #000; color: var(--in-button-primary-color);
min-width: var(--in-page-button-size); min-width: var(--in-page-button-size);
height: var(--in-page-button-size); height: var(--in-page-button-size);
line-height: var(--in-page-button-size); line-height: var(--in-page-button-size);
@ -60,14 +60,14 @@
text-decoration: none; text-decoration: none;
&:hover { &:hover {
background-color: #ececec; background-color: var(--in-button-primary-hover-background);
} }
&.disabled { &.disabled {
user-select: none; user-select: none;
pointer-events: none; pointer-events: none;
background-color: #ececec; background-color: var(--in-button-primary-disabled-background);
color: #616161; color: var(--in-button-primary-disabled-color);
} }
} }
</style> </style>

View File

@ -59,9 +59,9 @@
display: flex; display: flex;
gap: 1rem; gap: 1rem;
padding: 8px; padding: 8px;
background-color: #fff; background-color: var(--ina-card-background);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); box-shadow: var(--ina-card-shadow);
flex-grow: 1; flex-grow: 1;
transition: transform 100ms linear; transition: transform 100ms linear;

View File

@ -18,8 +18,7 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 8px 16px; padding: 8px 16px;
background: #00aaff; background: var(--ina-header-color);
background: linear-gradient(180deg, #00aaff 0%, #005bff 100%);
} }
.admin-user { .admin-user {
@ -27,9 +26,9 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 4px 8px 4px 4px; padding: 4px 8px 4px 4px;
background-color: #004edf; background-color: var(--ina-header-tag-color);
border-radius: 40px; border-radius: 40px;
color: #fff; color: var(--ina-header-tag-text-color);
& .admin-user-avatar { & .admin-user-avatar {
width: 32px; width: 32px;
@ -41,10 +40,10 @@
a { a {
text-decoration: none; text-decoration: none;
color: #fff; color: var(--ina-header-link-color);
&:visited { &:visited {
color: #fff; color: var(--ina-header-link-color);
} }
} }
</style> </style>

View File

@ -47,11 +47,11 @@
.admin-sidebar { .admin-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #dddddd; background-color: var(--ina-sidebar-color);
height: 100%; height: 100%;
max-width: 240px; max-width: 240px;
width: 100%; width: 100%;
border-right: 2px solid #b4b4b4; border-right: 2px solid var(--ina-sidebar-border-color);
& > nav { & > nav {
margin-top: 16px; margin-top: 16px;
@ -69,11 +69,11 @@
text-decoration: none; text-decoration: none;
&.active { &.active {
background-color: #c7c7c7; background-color: var(--ina-sidebar-link-active-color);
} }
&:hover { &:hover {
background-color: #e4e4e4; background-color: var(--ina-sidebar-link-hover-color);
} }
} }
} }
@ -82,6 +82,6 @@
.admin-sidebar, .admin-sidebar,
.admin-sidebar :global(a) { .admin-sidebar :global(a) {
color: #000; color: var(--ina-sidebar-link-text-color);
} }
</style> </style>

View File

@ -43,9 +43,9 @@
display: flex; display: flex;
gap: 1rem; gap: 1rem;
padding: 8px; padding: 8px;
background-color: #fff; background-color: var(--ina-card-background);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); box-shadow: var(--ina-card-shadow);
flex-grow: 1; flex-grow: 1;
transition: transform 100ms linear; transition: transform 100ms linear;

View File

@ -11,6 +11,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
flex-grow: 1;
} }
.page-inner { .page-inner {
@ -18,6 +19,7 @@
flex-direction: column; flex-direction: column;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
background: var(--in-container-background);
padding: 40px; padding: 40px;
max-width: 1080px; max-width: 1080px;
width: 100%; width: 100%;
@ -25,7 +27,6 @@
main { main {
min-height: 100vh; min-height: 100vh;
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }

View File

@ -20,6 +20,7 @@
flex-direction: column; flex-direction: column;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
background: var(--in-container-background);
padding: 40px; padding: 40px;
max-width: 600px; max-width: 600px;
width: 100%; width: 100%;

View File

@ -8,7 +8,7 @@
:global(input) { :global(input) {
background-color: var(--in-input-background); background-color: var(--in-input-background);
color: var(--in-input-color); color: var(--in-input-color);
border: 2px solid var(--in-input-border-color); border: var(--in-input-border);
&:not([type]), &:not([type]),
&[type='text'], &[type='text'],
@ -17,7 +17,7 @@
&[type='email'] { &[type='email'] {
padding: 8px; padding: 8px;
font-size: 1rem; font-size: 1rem;
border-radius: 6px; border-radius: 4px;
&:focus-visible { &:focus-visible {
outline: var(--in-focus-outline); outline: var(--in-focus-outline);
@ -34,6 +34,11 @@
border-color: var(--in-input-border-color-invalid); border-color: var(--in-input-border-color-invalid);
} }
} }
&[type='file'] {
padding: 1rem;
border-radius: 4px;
}
} }
.form-control > :global(label) { .form-control > :global(label) {

View File

@ -57,6 +57,7 @@
"authorizationsHint": "These users have authorized this application at least once. You may assign application privileges to each user individually.", "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", "revoked": "This authorization has been revoked by the user",
"noAuthorizations": "There are no authorizations on record for this application.", "noAuthorizations": "There are no authorizations on record for this application.",
"success": "Success!",
"privileges": { "privileges": {
"title": "Application privileges", "title": "Application privileges",
"name": "Privilege name", "name": "Privilege name",
@ -124,7 +125,8 @@
"invalidPrivilege": "Invalid privilege provided.", "invalidPrivilege": "Invalid privilege provided.",
"invalidEmail": "Invalid email address.", "invalidEmail": "Invalid email address.",
"emailExists": "This email address is already added.", "emailExists": "This email address is already added.",
"noFile": "Please upload a file first." "noFile": "Please upload a file first.",
"tooManyTimes": "You are doing that too much, please, slow down!"
} }
} }
} }

View File

@ -1,6 +1,6 @@
{ {
"description": "{{siteName}} is a Single-Sign-On service used by other applications.", "description": "{{siteName}} is a Single-Sign-On service used by other applications.",
"cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is&nbsp;<a href=\"https://git.icynet.eu/IcyNetwork/icynet-auth-server\" target=\"_blank\">completely open source</a> and can be audited by anyone.", "cookieDisclaimer": "The website may use temporary cookies for storing your login session and ensuring your security. This web service is&nbsp;<a href=\"https://git.icynet.eu/IcyNetwork/sso-core\" target=\"_blank\">completely open source</a> and can be audited by anyone.",
"submit": "Submit", "submit": "Submit",
"cancel": "Cancel", "cancel": "Cancel",
"manage": "Manage", "manage": "Manage",

View File

@ -1,5 +1,5 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { UserSession } from './users'; import { Users, type UserSession } from './users';
import { hasPrivileges, type RequiredPrivileges } from '$lib/utils'; import { hasPrivileges, type RequiredPrivileges } from '$lib/utils';
export class AdminUtils { export class AdminUtils {
@ -8,4 +8,23 @@ export class AdminUtils {
error(403, 'Forbidden resource'); error(403, 'Forbidden resource');
} }
} }
/**
* Get the user who is committing an action.
* @param locals App locals
* @param privileges Required privileges for action
* @returns Current user and their session cookie
*/
static async getActionUser(locals: App.Locals, privileges: RequiredPrivileges) {
const userSession = locals.session.data?.user;
const currentUser = await Users.getBySession(userSession);
if (!userSession || !currentUser) {
return error(403);
}
userSession.privileges = await Users.getUserPrivileges(currentUser);
AdminUtils.checkPrivileges(userSession, privileges);
return { currentUser, userSession };
}
} }

View File

@ -0,0 +1,9 @@
import privilegesSeed from './privileges';
const seeds = [privilegesSeed];
export const runSeeds = async () => {
for (const fn of seeds) {
await fn();
}
};

View File

@ -0,0 +1,28 @@
import { eq } from 'drizzle-orm';
import { db, privilege } from '..';
/**
* System privileges which must always exist in the database.
*/
const privileges = [
'admin',
'admin:user',
'admin:user:privilege',
'admin:oauth2',
'admin:audit',
'admin:document',
'self:oauth2',
'self:oauth2:implicit',
'self:oauth2:create'
];
export default async function privilegesSeed() {
for (const priv of privileges) {
const [exists] = await db
.select({ id: privilege.id })
.from(privilege)
.where(eq(privilege.name, priv));
if (exists) continue;
await db.insert(privilege).values({ name: priv });
}
}

View File

@ -11,7 +11,7 @@ ${PUBLIC_SITE_NAME}
${inviter} has invited you to edit the "${clientName}" application on ${PUBLIC_SITE_NAME}. ${inviter} has invited you to edit the "${clientName}" application on ${PUBLIC_SITE_NAME}.
Please click on the following link to accept the invitation. Please use the following link to accept the invitation.
Accept invitation: ${url} Accept invitation: ${url}
@ -22,7 +22,7 @@ This email was sent to you because someone invited you to contribute to an appli
<p>${inviter} has invited you to edit the "${clientName}" application on ${PUBLIC_SITE_NAME}. <p>${inviter} has invited you to edit the "${clientName}" application on ${PUBLIC_SITE_NAME}.
<p><b>Please click on the following link to accept the invitation ${PUBLIC_SITE_NAME}.</b></p> <p><b>Please use the following link to accept the invitation:</b></p>
<p>Accept invitation: <a href="${url}" target="_blank">${url}</a></p> <p>Accept invitation: <a href="${url}" target="_blank">${url}</a></p>

View File

@ -15,7 +15,7 @@ import {
} from '$lib/server/drizzle'; } from '$lib/server/drizzle';
import { Emails, OAuth2InvitationEmail } from '$lib/server/email'; import { Emails, OAuth2InvitationEmail } from '$lib/server/email';
import { Uploads } from '$lib/server/upload'; import { Uploads } from '$lib/server/upload';
import { UserTokens } from '$lib/server/users'; import { UserTokens, Users } from '$lib/server/users';
import type { PaginationMeta } from '$lib/types'; import type { PaginationMeta } from '$lib/types';
import { and, count, eq, like, or, sql } from 'drizzle-orm'; import { and, count, eq, like, or, sql } from 'drizzle-orm';
@ -64,6 +64,9 @@ export class OAuth2Clients {
'openid' 'openid'
]; ];
// Non-administrator capabilities
public static implicitGrantTypes = ['id_token', 'implicit'];
public static userSetGrants = ['authorization_code', 'client_credentials', 'refresh_token'];
public static userSetScopes = [ public static userSetScopes = [
'profile', 'profile',
'picture', 'picture',
@ -72,12 +75,6 @@ export class OAuth2Clients {
'management', 'management',
'openid' 'openid'
]; ];
public static userSetGrants = [
'authorization_code',
'client_credentials',
'refresh_token',
'id_token'
];
public static describedScopes = ['email', 'picture', 'account']; public static describedScopes = ['email', 'picture', 'account'];
public static alwaysPresentScopes = ['profile']; public static alwaysPresentScopes = ['profile'];
@ -317,7 +314,7 @@ export class OAuth2Clients {
client_id: uid, client_id: uid,
client_secret: secret, client_secret: secret,
grants: 'authorization_code', grants: 'authorization_code',
scope: 'profile', scope: OAuth2Clients.joinScope(OAuth2Clients.alwaysPresentScopes),
ownerId: subject.id, ownerId: subject.id,
created_at: new Date(), created_at: new Date(),
activated: 1, activated: 1,
@ -382,6 +379,13 @@ export class OAuth2Clients {
} }
static async addManager(client: OAuth2Client, actor: User, subject: User) { static async addManager(client: OAuth2Client, actor: User, subject: User) {
// Check if the subject has access to OAuth2 management already,
// if they do not, give them the regular user's OAuth2 management privilege.
const privList = await Users.getUserPrivileges(subject);
if (!privList.some((name) => ['self:oauth2', 'admin:oauth2'].includes(name))) {
await Users.grantPrivilege(subject, 'self:oauth2');
}
await db.insert(oauth2ClientManager).values({ await db.insert(oauth2ClientManager).values({
clientId: client.id, clientId: client.id,
userId: subject.id, userId: subject.id,

View File

@ -1,4 +1,4 @@
import { count, eq, like, or, sql } from 'drizzle-orm'; import { asc, count, eq, like, or, sql } from 'drizzle-orm';
import { import {
db, db,
privilege, privilege,
@ -8,16 +8,17 @@ import {
type User type User
} from '../drizzle'; } from '../drizzle';
import type { Paginated, PaginationMeta } from '$lib/types'; import type { Paginated, PaginationMeta } from '$lib/types';
import type { RequiredPrivileges } from '$lib/utils';
import { Users } from '.';
import { error } from '@sveltejs/kit';
import { AdminUtils } from '../admin-utils';
export interface AdminUserListItem extends Omit<User, 'password'> { export interface AdminUserListItem extends Omit<User, 'password'> {
privileges: Privilege[]; privileges: Privilege[];
} }
export class UsersAdmin { export class UsersAdmin {
/**
* Map a list of users with their privileges.
* @param junkList User-privilege amalgamation
* @returns List of users
*/
static mergeUserResponse( static mergeUserResponse(
junkList: { junkList: {
user: User; user: User;
@ -45,6 +46,11 @@ export class UsersAdmin {
}, []); }, []);
} }
/**
* Get a list of all users and their privileges.
* @param params Search params
* @returns List of users
*/
static async getAllUsers({ static async getAllUsers({
filter, filter,
offset = 0, offset = 0,
@ -69,14 +75,26 @@ export class UsersAdmin {
.from(user) .from(user)
.where(searchExpression); .where(searchExpression);
const junkList = await db const baseQuery = db
.select() .select({ id: user.id })
.from(user) .from(user)
.where(searchExpression)
.orderBy(asc(user.id))
.limit(limit)
.offset(offset)
.as('searchBase');
const junkList = await db
.select({
user: user,
user_privileges_privilege: userPrivilegesPrivilege,
privilege: privilege
})
.from(baseQuery)
.innerJoin(user, eq(baseQuery.id, user.id))
.leftJoin(userPrivilegesPrivilege, eq(userPrivilegesPrivilege.userId, user.id)) .leftJoin(userPrivilegesPrivilege, eq(userPrivilegesPrivilege.userId, user.id))
.leftJoin(privilege, eq(userPrivilegesPrivilege.privilegeId, privilege.id)) .leftJoin(privilege, eq(userPrivilegesPrivilege.privilegeId, privilege.id))
.where(searchExpression) .where(searchExpression);
.limit(limit)
.offset(offset);
const meta: PaginationMeta = { const meta: PaginationMeta = {
rowCount, rowCount,
@ -92,6 +110,11 @@ export class UsersAdmin {
}; };
} }
/**
* Get an user's information by their UUID.
* @param uuid User UUID
* @returns User infor
*/
static async getUserDetails(uuid: string) { static async getUserDetails(uuid: string) {
const junkList = await db const junkList = await db
.select() .select()
@ -102,17 +125,4 @@ export class UsersAdmin {
const [userInfo] = UsersAdmin.mergeUserResponse(junkList); const [userInfo] = UsersAdmin.mergeUserResponse(junkList);
return userInfo; return userInfo;
} }
static async getActionUser(locals: App.Locals, privileges: RequiredPrivileges) {
const userSession = locals.session.data?.user;
const currentUser = await Users.getBySession(userSession);
if (!userSession || !currentUser) {
return error(403);
}
userSession.privileges = await Users.getUserPrivileges(currentUser);
AdminUtils.checkPrivileges(userSession, privileges);
return { currentUser, userSession };
}
} }

View File

@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs';
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm'; import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
import { db, privilege, user, userPrivilegesPrivilege, type User } from '../drizzle'; import { db, privilege, user, userPrivilegesPrivilege, type User } from '../drizzle';
import type { UserSession } from './types'; import type { UserSession } from './types';
import { redirect } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit';
import { CryptoUtils } from '../crypto-utils'; import { CryptoUtils } from '../crypto-utils';
import { EMAIL_ENABLED } from '$env/static/private'; import { EMAIL_ENABLED } from '$env/static/private';
import { Emails, ForgotPasswordEmail, InvitationEmail, RegistrationEmail } from '../email'; import { Emails, ForgotPasswordEmail, InvitationEmail, RegistrationEmail } from '../email';
@ -10,6 +10,11 @@ import { PUBLIC_SITE_NAME, PUBLIC_URL } from '$env/static/public';
import { UserTokens } from './tokens'; import { UserTokens } from './tokens';
export class Users { export class Users {
/**
* Get user by their primary key ID.
* @param id User ID
* @returns User
*/
static async getById(id: number): Promise<User | undefined> { static async getById(id: number): Promise<User | undefined> {
const [result] = await db const [result] = await db
.select() .select()
@ -19,6 +24,11 @@ export class Users {
return result; return result;
} }
/**
* Get user by their UUID (public-facing ID).
* @param id User ID
* @returns User
*/
static async getByUuid(uuid: string, activatedCheck = true): Promise<User | undefined> { static async getByUuid(uuid: string, activatedCheck = true): Promise<User | undefined> {
const [result] = await db const [result] = await db
.select() .select()
@ -28,15 +38,30 @@ export class Users {
return result; return result;
} }
/**
* Get an user by their login (username or email)
* @param login Username or email address
* @returns User
*/
static async getByLogin(login: string): Promise<User | undefined> { static async getByLogin(login: string): Promise<User | undefined> {
const [result] = await db const [result] = await db
.select() .select()
.from(user) .from(user)
.where(and(or(eq(user.email, login), eq(user.username, login)), eq(user.activated, 1))) .where(
and(
or(eq(user.email, login), sql`lower(${user.username}) = ${login.toLowerCase()}`),
eq(user.activated, 1)
)
)
.limit(1); .limit(1);
return result; return result;
} }
/**
* Get an user by their session cookie.
* @param session Session from cookie
* @returns User
*/
static async getBySession(session?: UserSession): Promise<User | undefined> { static async getBySession(session?: UserSession): Promise<User | undefined> {
if (!session) return undefined; if (!session) return undefined;
const [result] = await db const [result] = await db
@ -47,18 +72,39 @@ export class Users {
return result; return result;
} }
/**
* Update an user.
* @param subject User to update
* @param fields Fields to set
*/
static async update(subject: User, fields: Partial<User>) { static async update(subject: User, fields: Partial<User>) {
return db.update(user).set(fields).where(eq(user.id, subject.id)); return db.update(user).set(fields).where(eq(user.id, subject.id));
} }
/**
* Check an user's password.
* @param user User
* @param password Plaintext password
* @returns Boolean
*/
static async validatePassword(user: User, password: string): Promise<boolean> { static async validatePassword(user: User, password: string): Promise<boolean> {
return bcrypt.compare(password, user.password as string); return bcrypt.compare(password, user.password as string);
} }
/**
* Hash a password.
* @param password Plaintext password
* @returns Hashed password
*/
static async hashPassword(password: string): Promise<string> { static async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10); return bcrypt.hash(password, 10);
} }
/**
* Create a session cookie from user information.
* @param user User
* @returns Cookie
*/
static async toSession(user: User): Promise<UserSession> { static async toSession(user: User): Promise<UserSession> {
return { return {
uid: user.id, uid: user.id,
@ -68,6 +114,12 @@ export class Users {
}; };
} }
/**
* Get an user from the request locals or redirect to the login page.
* @param locals App locals
* @param url URL
* @returns User
*/
static async readSessionOrRedirect(locals: App.Locals, url: URL) { static async readSessionOrRedirect(locals: App.Locals, url: URL) {
const currentUser = await Users.getBySession(locals.session.data?.user); const currentUser = await Users.getBySession(locals.session.data?.user);
if (!currentUser) { if (!currentUser) {
@ -77,6 +129,12 @@ export class Users {
return currentUser; return currentUser;
} }
/**
* Check if an user exists with the provided username and/or email address.
* @param username Username
* @param email Email address
* @returns true if existing user does not exist
*/
static async checkRegistration(username: string, email: string) { static async checkRegistration(username: string, email: string) {
return !( return !(
await db await db
@ -91,6 +149,11 @@ export class Users {
)?.length; )?.length;
} }
/**
* Get user information by activation token.
* @param token Activation token
* @returns User information
*/
static async getActivationToken(token: string) { static async getActivationToken(token: string) {
const returnedToken = await UserTokens.getByToken(token, 'activation'); const returnedToken = await UserTokens.getByToken(token, 'activation');
if (!returnedToken?.userId) return undefined; if (!returnedToken?.userId) return undefined;
@ -106,6 +169,11 @@ export class Users {
}; };
} }
/**
* Activate an user by an activation token.
* @param token Token
* @param subject User
*/
static async activateUserBy(token: string, subject: User) { static async activateUserBy(token: string, subject: User) {
await db await db
.update(user) .update(user)
@ -114,6 +182,11 @@ export class Users {
await UserTokens.remove(token); await UserTokens.remove(token);
} }
/**
* Register a new user.
* @param details User details
* @returns New User
*/
static async register({ static async register({
username, username,
displayName, displayName,
@ -149,6 +222,10 @@ export class Users {
return newUser; return newUser;
} }
/**
* Send an activation email to an user.
* @param user User
*/
static async sendRegistrationEmail(user: User) { static async sendRegistrationEmail(user: User) {
const token = await UserTokens.create( const token = await UserTokens.create(
'activation', 'activation',
@ -172,6 +249,10 @@ export class Users {
} }
} }
/**
* Send a password reset email to an user.
* @param user User
*/
static async sendPasswordEmail(user: User) { static async sendPasswordEmail(user: User) {
const token = await UserTokens.create('password', new Date(Date.now() + 3600 * 1000), user.id); const token = await UserTokens.create('password', new Date(Date.now() + 3600 * 1000), user.id);
const params = new URLSearchParams({ token: token.token }); const params = new URLSearchParams({ token: token.token });
@ -192,6 +273,10 @@ export class Users {
} }
} }
/**
* Send a registration invite to an email address.
* @param email Email to invite
*/
static async sendInvitationEmail(email: string) { static async sendInvitationEmail(email: string) {
const token = await UserTokens.create( const token = await UserTokens.create(
'invite', 'invite',
@ -215,6 +300,11 @@ export class Users {
} }
} }
/**
* Get all available privileges.
* @param clientId (optional) Client privileges
* @returns Available privileges
*/
static async getAvailablePrivileges(clientId?: number) { static async getAvailablePrivileges(clientId?: number) {
return await db return await db
.select() .select()
@ -222,6 +312,12 @@ export class Users {
.where(clientId ? eq(privilege.clientId, clientId) : isNull(privilege.clientId)); .where(clientId ? eq(privilege.clientId, clientId) : isNull(privilege.clientId));
} }
/**
* Get an user's privileges.
* @param subject User
* @param clientId (optional) OAuth2 client ID, returns privileges for a specific client only
* @returns User privileges (string list)
*/
static async getUserPrivileges(subject: User, clientId?: number) { static async getUserPrivileges(subject: User, clientId?: number) {
const list = await db const list = await db
.select({ .select({
@ -242,7 +338,64 @@ export class Users {
); );
} }
/**
* Grant a new privilege to a user.
*
* **This can only be used to grant system privileges.**
* @see setUserPrivileges for general privilege changes.
* @param subject Recipient
* @param name Privilege name
* @returns Boolean, whether the privilege was granted or not.
*/
static async grantPrivilege(subject: User, name: string) {
const [existingPrivilege] = await db
.select({ id: privilege.id })
.from(privilege)
.where(and(eq(privilege.name, name), isNull(privilege.clientId)));
if (!existingPrivilege) return false;
const [alreadyHas] = await db
.select({ privilegeId: userPrivilegesPrivilege.privilegeId })
.from(userPrivilegesPrivilege)
.where(
and(
eq(userPrivilegesPrivilege.userId, subject.id),
eq(userPrivilegesPrivilege.privilegeId, existingPrivilege.id)
)
);
if (alreadyHas) return true;
await db.insert(userPrivilegesPrivilege).values({
privilegeId: existingPrivilege.id,
userId: subject.id
});
return true;
}
/**
* Set an user's privileges to an array of IDs.
* @param subject User
* @param privilegeIds Privileges
* @param clientId (optional) OAuth2 client ID, updates privileges related to a specific client only.
*/
static async setUserPrivileges(subject: User, privilegeIds: number[], clientId?: number) { static async setUserPrivileges(subject: User, privilegeIds: number[], clientId?: number) {
// Prevent tricksters from trying to give themselves system privileges
// through the OAuth2 client privilege system. (or remove them from others)
// The privileges in question must actually be related to the specified client.
if (clientId) {
for (const id of privilegeIds) {
const [exists] = await db
.select({ id: privilege.id })
.from(privilege)
.where(and(eq(privilege.id, id), eq(privilege.clientId, clientId)));
if (!exists) {
// TODO: logging
return error(403);
}
}
}
const current = await db const current = await db
.select({ .select({
privilegeId: userPrivilegesPrivilege.privilegeId privilegeId: userPrivilegesPrivilege.privilegeId
@ -285,6 +438,11 @@ export class Users {
} }
} }
/**
* Hide an email addresses username part partially as a hint.
* @param email Email address
* @returns Slightly redacted email address
*/
static anonymizeEmail(email: string) { static anonymizeEmail(email: string) {
const [name, domain] = email.split('@'); const [name, domain] = email.split('@');
const namePart = `${name.charAt(0)}${''.padStart(name.length - 2, '*')}${name.charAt(name.length - 1)}`; const namePart = `${name.charAt(0)}${''.padStart(name.length - 2, '*')}${name.charAt(name.length - 1)}`;

View File

@ -17,6 +17,7 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { PUBLIC_SITE_NAME } from '$env/static/public'; import { PUBLIC_SITE_NAME } from '$env/static/public';
import FormErrors from '$lib/components/form/FormErrors.svelte'; import FormErrors from '$lib/components/form/FormErrors.svelte';
import TitleRow from '$lib/components/container/TitleRow.svelte';
export let data: PageData; export let data: PageData;
export let form: ActionData; export let form: ActionData;
@ -58,7 +59,12 @@
</svelte:head> </svelte:head>
<MainContainer> <MainContainer>
<TitleRow>
<h1>{PUBLIC_SITE_NAME}</h1> <h1>{PUBLIC_SITE_NAME}</h1>
<LogoutButton />
</TitleRow>
<SplitView> <SplitView>
<div> <div>
<h2>{$t('account.title')}</h2> <h2>{$t('account.title')}</h2>
@ -160,8 +166,6 @@
<Button variant="primary" type="submit">{$t('account.submit')}</Button> <Button variant="primary" type="submit">{$t('account.submit')}</Button>
</FormWrapper> </FormWrapper>
</form> </form>
<LogoutButton />
</ViewColumn> </ViewColumn>
</div> </div>

View File

@ -4,6 +4,7 @@
import { PUBLIC_SITE_NAME } from '$env/static/public'; import { PUBLIC_SITE_NAME } from '$env/static/public';
import Alert from '$lib/components/Alert.svelte'; import Alert from '$lib/components/Alert.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import ButtonRow from '$lib/components/container/ButtonRow.svelte';
import ColumnView from '$lib/components/container/ColumnView.svelte'; import ColumnView from '$lib/components/container/ColumnView.svelte';
import SideContainer from '$lib/components/container/SideContainer.svelte'; import SideContainer from '$lib/components/container/SideContainer.svelte';
import FormControl from '$lib/components/form/FormControl.svelte'; import FormControl from '$lib/components/form/FormControl.svelte';
@ -88,9 +89,12 @@
<input type="email" name="email" id="password-email" autocomplete="email" /> <input type="email" name="email" id="password-email" autocomplete="email" />
</FormControl> </FormControl>
{/if} {/if}
<ButtonRow>
<Button type="submit" variant="primary" disabled={submitted} <Button type="submit" variant="primary" disabled={submitted}
>{$t('account.submit')}</Button >{$t('account.submit')}</Button
> >
<div><a href="/login">{$t('account.login.title')}</a></div>
</ButtonRow>
</FormWrapper> </FormWrapper>
</form> </form>
{/if} {/if}

View File

@ -12,6 +12,7 @@
import FormSection from '$lib/components/form/FormSection.svelte'; import FormSection from '$lib/components/form/FormSection.svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import FormErrors from '$lib/components/form/FormErrors.svelte'; import FormErrors from '$lib/components/form/FormErrors.svelte';
import ButtonRow from '$lib/components/container/ButtonRow.svelte';
export let data: PageData; export let data: PageData;
export let form: ActionData; export let form: ActionData;
@ -120,12 +121,14 @@
</FormControl> </FormControl>
</FormSection> </FormSection>
<ButtonRow>
<Button type="submit" variant="primary" disabled={submitted} <Button type="submit" variant="primary" disabled={submitted}
>{$t('account.register.submit')}</Button >{$t('account.register.submit')}</Button
> >
<div><a href="/login">{$t('account.login.title')}</a></div>
</ButtonRow>
</FormWrapper> </FormWrapper>
</form> </form>
{/if} {/if}
<div><a href="/login">{$t('account.login.title')}</a></div>
</ColumnView> </ColumnView>
</SideContainer> </SideContainer>

View File

@ -1,8 +1,19 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate } from '$app/navigation';
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'; import AdminHeader from '$lib/components/admin/AdminHeader.svelte';
import AdminSidebar from '$lib/components/admin/AdminSidebar.svelte'; import AdminSidebar from '$lib/components/admin/AdminSidebar.svelte';
import { tick } from 'svelte';
import type { PageData } from './$types'; import type { PageData } from './$types';
export let data: PageData; export let data: PageData;
let container: HTMLElement;
// Reset container scroll.
afterNavigate(async ({ type }) => {
if (type !== 'link') return;
await tick();
container?.scrollTo(0, 0);
});
</script> </script>
<div class="admin-wrapper"> <div class="admin-wrapper">
@ -11,19 +22,13 @@
<div class="sidebar-wrapper"> <div class="sidebar-wrapper">
<AdminSidebar user={data.user} /> <AdminSidebar user={data.user} />
<main> <main bind:this={container}>
<slot /> <slot />
</main> </main>
</div> </div>
</div> </div>
<style> <style>
.admin-wrapper {
--in-text-color: #000;
--in-link-color: #000;
--in-error-color: #ff8080;
}
.admin-wrapper { .admin-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -40,8 +45,8 @@
& > main { & > main {
overflow: auto; overflow: auto;
flex-grow: 1; flex-grow: 1;
background-color: #ececec; background-color: var(--ina-background-color);
color: #000; color: var(--in-text-color);
padding: 16px; padding: 16px;
} }
} }

View File

@ -28,6 +28,7 @@ export const load = async ({ parent, url }) => {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const fullPrivileges = hasPrivileges(user.privileges, ['admin:oauth2']); const fullPrivileges = hasPrivileges(user.privileges, ['admin:oauth2']);
const createPrivileges = hasPrivileges(user.privileges, [['admin:oauth2', 'self:oauth2:create']]);
const data = await OAuth2Clients.getClientByAdminUser(currentUser as User, { const data = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
filter, filter,
@ -39,6 +40,7 @@ export const load = async ({ parent, url }) => {
return { return {
fullPrivileges, fullPrivileges,
createPrivileges,
...data ...data
}; };
}; };

View File

@ -18,7 +18,9 @@
<TitleRow> <TitleRow>
<h1>{$t('admin.oauth2.title')}</h1> <h1>{$t('admin.oauth2.title')}</h1>
{#if data.createPrivileges}
<a href="oauth2/new">{$t('admin.oauth2.new')}</a> <a href="oauth2/new">{$t('admin.oauth2.new')}</a>
{/if}
</TitleRow> </TitleRow>
<ColumnView> <ColumnView>

View File

@ -5,10 +5,10 @@ import type { OAuth2Client, User } from '$lib/server/drizzle';
import { OAuth2ClientURLType, OAuth2Clients } from '$lib/server/oauth2'; import { OAuth2ClientURLType, OAuth2Clients } from '$lib/server/oauth2';
import { Uploads } from '$lib/server/upload.js'; import { Uploads } from '$lib/server/upload.js';
import { Users } from '$lib/server/users'; import { Users } from '$lib/server/users';
import { UsersAdmin } from '$lib/server/users/admin';
import { hasPrivileges } from '$lib/utils'; import { hasPrivileges } from '$lib/utils';
import { emailRegex, privilegeRegex } from '$lib/validators.js'; import { emailRegex, privilegeRegex } from '$lib/validators.js';
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, redirect } from '@sveltejs/kit';
import { RateLimiter } from 'sveltekit-rate-limiter/server';
interface AddUrlRequest { interface AddUrlRequest {
type: OAuth2ClientURLType; type: OAuth2ClientURLType;
@ -30,9 +30,23 @@ interface InviteRequest {
email: string; email: string;
} }
export const actions = { const oneOffLimiter = new RateLimiter({
update: async ({ locals, request, params: { uuid } }) => { IP: [1, 'm']
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ });
const inviteLimiter = new RateLimiter({
IP: [3, 'm']
});
/**
* Return the current user's information and the client the action is performed on.
* Errors if client doesn't exist or user does not have permission to change it.
* @param locals App locals
* @param uuid Client ID
* @returns Current user, their session, full OAuth2 privileges flag and the actionable client
*/
const getActionData = async (locals: App.Locals, uuid: string) => {
const { currentUser, userSession } = await AdminUtils.getActionUser(locals, [
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2']
]); ]);
@ -50,6 +64,16 @@ export const actions = {
return error(404, 'Client not found'); return error(404, 'Client not found');
} }
return { currentUser, userSession, fullPrivileges, details: details as OAuth2Client };
};
export const actions = {
/**
* Update the OAuth2 Client general information.
*/
update: async ({ locals, request, params: { uuid } }) => {
const { details, fullPrivileges } = await getActionData(locals, uuid);
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'],
@ -71,7 +95,7 @@ export const actions = {
return fail(403, { errors: ['invalidDescription'] }); return fail(403, { errors: ['invalidDescription'] });
} }
await OAuth2Clients.update(details as OAuth2Client, { await OAuth2Clients.update(details, {
title, title,
description, description,
verified: actuallyVerified, verified: actuallyVerified,
@ -80,24 +104,13 @@ export const actions = {
return { errors: [] }; return { errors: [] };
}, },
/**
* Permanently delete the OAuth2 Client.
*
* Only works for disabled clients, and only the owner or admin can do it.
*/
delete: async ({ locals, params: { uuid } }) => { delete: async ({ locals, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ const { currentUser, fullPrivileges, details } = await getActionData(locals, uuid);
['admin:oauth2', 'self:oauth2']
]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: fullPrivileges,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
if (details.activated === 1) { if (details.activated === 1) {
return fail(400, { errors: ['deleteActivated'] }); return fail(400, { errors: ['deleteActivated'] });
@ -107,85 +120,57 @@ export const actions = {
return fail(403, { errors: ['forbidden'] }); return fail(403, { errors: ['forbidden'] });
} }
await OAuth2Clients.deleteClient(details as OAuth2Client); await OAuth2Clients.deleteClient(details);
return redirect(303, '/ssoadmin/oauth2'); return redirect(303, '/ssoadmin/oauth2');
}, },
regenerate: async ({ locals, params: { uuid } }) => { /**
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ * Regenerate OAuth2 Client secret key.
['admin:oauth2', 'self:oauth2'] *
]); * Only the owner or admin can do it.
*/
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']); regenerate: async (event) => {
const { const {
list: [details] locals,
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { params: { uuid }
clientId: uuid, } = event;
listAll: fullPrivileges, const { currentUser, fullPrivileges, details } = await getActionData(locals, uuid);
omitSecret: false
});
if (!details) { if (!fullPrivileges && details.ownerId !== currentUser.id) {
return error(404, 'Client not found');
}
if (!fullPrivileges && !details.isOwner) {
return fail(403, { errors: ['forbidden'] }); return fail(403, { errors: ['forbidden'] });
} }
await OAuth2Clients.update(details as OAuth2Client, { // Allow secret regeneration only once per minute.
if (!fullPrivileges && (await oneOffLimiter.isLimited(event))) {
return fail(429, { errors: ['tooManyTimes'] });
}
await OAuth2Clients.update(details, {
client_secret: CryptoUtils.generateSecret() client_secret: CryptoUtils.generateSecret()
}); });
return { errors: [] }; return { errors: [] };
}, },
/**
* Remove an URL reference.
*/
removeUrl: async ({ locals, url, params: { uuid } }) => { removeUrl: async ({ locals, url, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ const { details } = await getActionData(locals, uuid);
['admin:oauth2', 'self:oauth2']
]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: fullPrivileges,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const id = Number(url.searchParams.get('id')); const id = Number(url.searchParams.get('id'));
if (isNaN(id)) { if (isNaN(id)) {
return fail(400, { errors: ['invalidUrlId'] }); return fail(400, { errors: ['invalidUrlId'] });
} }
await OAuth2Clients.deleteUrl(details as OAuth2Client, id); await OAuth2Clients.deleteUrl(details, id);
return { errors: [] }; return { errors: [] };
}, },
/**
* Add an URL reference.
*/
addUrl: async ({ locals, request, params: { uuid } }) => { addUrl: async ({ locals, request, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ const { details } = await getActionData(locals, uuid);
['admin:oauth2', 'self:oauth2']
]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: fullPrivileges,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const body = await request.formData(); const body = await request.formData();
const { type, url } = Changesets.take<AddUrlRequest>(['type', 'url'], body); const { type, url } = Changesets.take<AddUrlRequest>(['type', 'url'], body);
@ -197,56 +182,30 @@ export const actions = {
return fail(400, { errors: ['invalidUrl'] }); return fail(400, { errors: ['invalidUrl'] });
} }
await OAuth2Clients.addUrl(details as OAuth2Client, type, url); await OAuth2Clients.addUrl(details, type, url);
return { errors: [] }; return { errors: [] };
}, },
/**
* Remove a privilege reference.
*/
removePrivilege: async ({ locals, url, params: { uuid } }) => { removePrivilege: async ({ locals, url, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ const { details } = await getActionData(locals, uuid);
['admin:oauth2', 'self:oauth2']
]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: fullPrivileges,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const id = Number(url.searchParams.get('id')); const id = Number(url.searchParams.get('id'));
if (isNaN(id)) { if (isNaN(id)) {
return fail(400, { errors: ['invalidPrivilegeId'] }); return fail(400, { errors: ['invalidPrivilegeId'] });
} }
await OAuth2Clients.deletePrivilege(details as OAuth2Client, id); await OAuth2Clients.deletePrivilege(details, id);
return { errors: [] }; return { errors: [] };
}, },
/**
* Add a privilege reference.
*/
addPrivilege: async ({ locals, request, params: { uuid } }) => { addPrivilege: async ({ locals, request, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ const { details } = await getActionData(locals, uuid);
['admin:oauth2', 'self:oauth2']
]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: fullPrivileges,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const body = await request.formData(); const body = await request.formData();
const { name } = Changesets.take<AddPrivilegeRequest>(['name'], body); const { name } = Changesets.take<AddPrivilegeRequest>(['name'], body);
@ -255,28 +214,15 @@ export const actions = {
return fail(400, { errors: ['invalidPrivilege'] }); return fail(400, { errors: ['invalidPrivilege'] });
} }
await OAuth2Clients.addPrivilege(details as OAuth2Client, name); await OAuth2Clients.addPrivilege(details, name);
return { errors: [] }; return { errors: [] };
}, },
/**
* Update the OAuth2 Client allowed grants list.
*/
grants: async ({ locals, request, params: { uuid } }) => { grants: async ({ locals, request, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ const { details, fullPrivileges } = await getActionData(locals, uuid);
['admin:oauth2', 'self:oauth2']
]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: fullPrivileges,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const allowedGrants = fullPrivileges const allowedGrants = fullPrivileges
? OAuth2Clients.availableGrantTypes ? OAuth2Clients.availableGrantTypes
@ -289,30 +235,17 @@ export const actions = {
(value, index, array) => allowedGrants.includes(value) && array.indexOf(value) === index (value, index, array) => allowedGrants.includes(value) && array.indexOf(value) === index
); );
await OAuth2Clients.update(details as OAuth2Client, { await OAuth2Clients.update(details, {
grants: deduplicatedAllowedGrants.join(' ') grants: deduplicatedAllowedGrants.join(' ')
}); });
return { errors: [] }; return { errors: [] };
}, },
/**
* Update the OAuth2 Client allowed scopes list.
*/
scopes: async ({ locals, request, params: { uuid } }) => { scopes: async ({ locals, request, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ const { details, fullPrivileges } = await getActionData(locals, uuid);
['admin:oauth2', 'self:oauth2']
]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: fullPrivileges,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const allowedScopes = fullPrivileges const allowedScopes = fullPrivileges
? OAuth2Clients.availableScopes ? OAuth2Clients.availableScopes
@ -325,30 +258,17 @@ export const actions = {
(value, index, array) => allowedScopes.includes(value) && array.indexOf(value) === index (value, index, array) => allowedScopes.includes(value) && array.indexOf(value) === index
); );
await OAuth2Clients.update(details as OAuth2Client, { await OAuth2Clients.update(details, {
scope: OAuth2Clients.joinScope(deduplicatedAllowedScopes) scope: OAuth2Clients.joinScope(deduplicatedAllowedScopes)
}); });
return { errors: [] }; return { errors: [] };
}, },
/**
* Update the OAuth2 Client icon picture.
*/
avatar: async ({ request, locals, params: { uuid } }) => { avatar: async ({ request, locals, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ const { currentUser, details } = await getActionData(locals, uuid);
['admin:oauth2', 'self:oauth2']
]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: fullPrivileges,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const formData = Object.fromEntries(await request.formData()); const formData = Object.fromEntries(await request.formData());
if (!(formData.file as File)?.name || (formData.file as File).name === 'undefined') { if (!(formData.file as File)?.name || (formData.file as File).name === 'undefined') {
@ -359,51 +279,33 @@ export const actions = {
const { file } = formData as { file: File }; const { file } = formData as { file: File };
await Uploads.saveClientAvatar(details as OAuth2Client, currentUser, file); await Uploads.saveClientAvatar(details, currentUser, file);
return { errors: [] }; return { errors: [] };
}, },
/**
* Delete the OAuth2 Client icon picture.
*/
removeAvatar: async ({ locals, params: { uuid } }) => { removeAvatar: async ({ locals, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ const { details } = await getActionData(locals, uuid);
['admin:oauth2', 'self:oauth2']
]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']); await Uploads.removeClientAvatar(details);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: fullPrivileges,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
await Uploads.removeClientAvatar(details as OAuth2Client);
return { errors: [] }; return { errors: [] };
}, },
invite: async ({ locals, request, params: { uuid } }) => { /**
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ * Invite a new manager to the OAuth2 Client.
['admin:oauth2', 'self:oauth2'] * Managers can do most changes the client's owner can.
]); *
* Only the owner or admin can do it.
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']); */
invite: async (event) => {
const { const {
list: [details] locals,
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, { request,
clientId: uuid, params: { uuid }
listAll: fullPrivileges, } = event;
omitSecret: false const { currentUser, details, fullPrivileges } = await getActionData(locals, uuid);
});
if (!details) {
return error(404, 'Client not found');
}
const body = await request.formData(); const body = await request.formData();
const { email } = Changesets.take<InviteRequest>(['email'], body); const { email } = Changesets.take<InviteRequest>(['email'], body);
@ -412,40 +314,41 @@ export const actions = {
return fail(400, { errors: ['invalidEmail'] }); return fail(400, { errors: ['invalidEmail'] });
} }
const managers = await OAuth2Clients.getManagers(details as OAuth2Client); if (details.ownerId !== currentUser.id && !fullPrivileges) {
return fail(403, { errors: ['forbidden'] });
}
const managers = await OAuth2Clients.getManagers(details);
if (managers.some((entry) => entry.email.toLowerCase() === email.toLowerCase())) { if (managers.some((entry) => entry.email.toLowerCase() === email.toLowerCase())) {
return fail(400, { errors: ['emailExists'] }); return fail(400, { errors: ['emailExists'] });
} }
if (!fullPrivileges && (await inviteLimiter.isLimited(event))) {
return fail(429, { errors: ['tooManyTimes'] });
}
await OAuth2Clients.sendManagerInvitationEmail(details as OAuth2Client, currentUser, email); await OAuth2Clients.sendManagerInvitationEmail(details as OAuth2Client, currentUser, email);
return { errors: [] }; return { errors: [] };
}, },
/**
* Remove a manager form the OAuth2 Client.
*
* Only the owner or admin can do it.
*/
removeManager: async ({ locals, url, params: { uuid } }) => { removeManager: async ({ locals, url, params: { uuid } }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ const { details, currentUser, fullPrivileges } = await getActionData(locals, uuid);
['admin:oauth2', 'self:oauth2']
]);
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
const {
list: [details]
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
clientId: uuid,
listAll: fullPrivileges,
omitSecret: false
});
if (!details) {
return error(404, 'Client not found');
}
const id = Number(url.searchParams.get('id')); const id = Number(url.searchParams.get('id'));
if (isNaN(id)) { if (isNaN(id)) {
return fail(400, { errors: ['invalidManagerId'] }); return fail(400, { errors: ['invalidManagerId'] });
} }
await OAuth2Clients.removeManager(details as OAuth2Client, id); if (details.ownerId !== currentUser.id && !fullPrivileges) {
return fail(403, { errors: ['forbidden'] });
}
await OAuth2Clients.removeManager(details, id);
return { errors: [] }; return { errors: [] };
} }
@ -474,15 +377,29 @@ export const load = async ({ params: { uuid }, parent }) => {
const users = await OAuth2Clients.getAuthorizedUsers(details as OAuth2Client); const users = await OAuth2Clients.getAuthorizedUsers(details as OAuth2Client);
const managers = await OAuth2Clients.getManagers(details as OAuth2Client); const managers = await OAuth2Clients.getManagers(details as OAuth2Client);
const availableGrants: string[] = [];
if (fullPrivileges) {
availableGrants.push(...OAuth2Clients.availableGrantTypes);
} else {
availableGrants.push(...OAuth2Clients.userSetGrants);
// Implicit grants can be allowed additionally.
if (hasPrivileges(user.privileges, ['self:oauth2:implicit'])) {
availableGrants.push(...OAuth2Clients.implicitGrantTypes);
}
}
const availableScopes = fullPrivileges
? OAuth2Clients.availableScopes
: OAuth2Clients.userSetScopes;
return { return {
users, users,
managers, managers,
availableUrls: OAuth2Clients.availableUrlTypes, availableUrls: OAuth2Clients.availableUrlTypes,
availablePrivileges: privileges, availablePrivileges: privileges,
availableGrants: fullPrivileges availableGrants,
? OAuth2Clients.availableGrantTypes availableScopes,
: OAuth2Clients.userSetGrants,
availableScopes: fullPrivileges ? OAuth2Clients.availableScopes : OAuth2Clients.userSetScopes,
fullPrivileges, fullPrivileges,
details details
}; };

View File

@ -15,6 +15,7 @@
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'; import { PUBLIC_SITE_NAME, PUBLIC_URL } from '$env/static/public';
import ActionButton from '$lib/components/ActionButton.svelte';
export let data: PageData; export let data: PageData;
export let form: ActionData; export let form: ActionData;
@ -27,10 +28,10 @@
$: noRedirects = !data.details.urls.some(({ type }) => type === 'redirect_uri'); $: noRedirects = !data.details.urls.some(({ type }) => type === 'redirect_uri');
$: availableUrls = data.availableUrls.filter((type) => { $: availableUrls = data.availableUrls.filter((type) => {
// Can have up to three redirect URIs, only one of other types // Can have up to five redirect URIs, only one of other types
const countOfType = data.details.urls.filter(({ type: subType }) => type === subType).length; const countOfType = data.details.urls.filter(({ type: subType }) => type === subType).length;
if (type === 'redirect_uri') { if (type === 'redirect_uri') {
return countOfType < 3; return countOfType < 5;
} }
return !countOfType; return !countOfType;
}); });
@ -59,15 +60,16 @@
> >
</div> </div>
{#if data.details.pictureId} {#if data.details.pictureId}
<form action="?/removeAvatar" method="POST"> <ActionButton action="?/removeAvatar">{$t('admin.oauth2.avatar.remove')}</ActionButton>
<Button type="submit" variant="link">{$t('admin.oauth2.avatar.remove')}</Button>
</form>
{/if} {/if}
</ColumnView> </ColumnView>
</AvatarCard> </AvatarCard>
<form action="?/update" method="POST"> <form action="?/update" method="POST">
<FormWrapper> <FormWrapper>
{#if form?.errors && !form.errors.length}
<Alert type="success">{$t('admin.oauth2.success')}</Alert>
{/if}
<FormErrors errors={form?.errors || []} prefix="admin.oauth2.errors" /> <FormErrors errors={form?.errors || []} prefix="admin.oauth2.errors" />
<FormSection> <FormSection>
<FormControl> <FormControl>
@ -131,13 +133,9 @@
<h2>{$t('admin.oauth2.actions')}</h2> <h2>{$t('admin.oauth2.actions')}</h2>
{#if data.fullPrivileges || data.details.isOwner} {#if data.fullPrivileges || data.details.isOwner}
{#if !data.details.activated} {#if !data.details.activated}
<form action="?/delete" method="POST"> <ActionButton action="?/delete">{$t('admin.oauth2.delete')}</ActionButton>
<Button type="submit" variant="link">{$t('admin.oauth2.delete')}</Button>
</form>
{:else} {:else}
<form action="?/regenerate" method="POST"> <ActionButton action="?/regenerate">{$t('admin.oauth2.regenerate')}</ActionButton>
<Button type="submit" variant="link">{$t('admin.oauth2.regenerate')}</Button>
</form>
{/if} {/if}
{/if} {/if}
@ -157,9 +155,7 @@
> >
</div> </div>
<form action="?/removeUrl&id={url.id}" method="POST"> <ActionButton action="?/removeUrl&id={url.id}">{$t('common.remove')}</ActionButton>
<Button type="submit" variant="link">{$t('common.remove')}</Button>
</form>
</div> </div>
{/each} {/each}
</div> </div>
@ -208,9 +204,10 @@
<span class="privilege-name" <span class="privilege-name"
><span class="privilege-id">{idPart}:</span>{rest.join(':')}</span ><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> <ActionButton action="?/removePrivilege&id={privilege.id}"
</form> >{$t('common.remove')}</ActionButton
>
</div> </div>
{/each} {/each}
</div> </div>
@ -249,7 +246,7 @@
<p>{$t('admin.oauth2.grantsHint')}</p> <p>{$t('admin.oauth2.grantsHint')}</p>
<form action="?/grants" method="POST"> <form action="?/grants" method="POST">
<div class="scope-cloud"> <div class="checkbox-grid">
{#each data.availableGrants as grant} {#each data.availableGrants as grant}
<FormControl> <FormControl>
<input <input
@ -272,7 +269,7 @@
<p>{$t('admin.oauth2.scopesHint')}</p> <p>{$t('admin.oauth2.scopesHint')}</p>
<form action="?/scopes" method="POST"> <form action="?/scopes" method="POST">
<div class="scope-cloud"> <div class="checkbox-grid">
{#each data.availableScopes as scope} {#each data.availableScopes as scope}
<FormControl> <FormControl>
<input <input
@ -301,9 +298,10 @@
{#each data.managers as user} {#each data.managers as user}
<div class="addremove-item"> <div class="addremove-item">
<b>{user.email}</b> <b>{user.email}</b>
<form action="?/removeManager&id={user.id}" method="POST">
<Button type="submit" variant="link">{$t('common.remove')}</Button> <ActionButton action="?/removeManager&id={user.id}"
</form> >{$t('common.remove')}</ActionButton
>
</div> </div>
{/each} {/each}
</div> </div>
@ -392,9 +390,11 @@
<div class="auth-user-name"> <div class="auth-user-name">
<b>{user.uuid}</b> ({user.name}) <b>{user.uuid}</b> ({user.name})
</div> </div>
<div class="auth-user-privileges"> <div class="auth-user-privileges">
{user.privileges.map(({ name }) => name).join(', ')} {user.privileges.map(({ name }) => name).join(', ')}
</div> </div>
{#if !user.current} {#if !user.current}
<b>{$t('admin.oauth2.revoked')}</b> <b>{$t('admin.oauth2.revoked')}</b>
{/if} {/if}
@ -425,13 +425,13 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background-color: #fff; background-color: var(--ina-card-background);
padding: 8px; padding: 8px;
border-radius: 4px; border-radius: 4px;
} }
& .privilege-id { & .privilege-id {
color: #646464; color: var(--ina-card-text-muted);
} }
& .url-type { & .url-type {
@ -444,10 +444,14 @@
} }
} }
.scope-cloud { .checkbox-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
column-gap: 1rem; column-gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
@media screen and (max-width: 768px) {
grid-template-columns: 1fr;
}
} }
</style> </style>

View File

@ -3,7 +3,7 @@ import { Changesets } from '$lib/server/changesets.js';
import type { OAuth2Client, User } from '$lib/server/drizzle'; import type { OAuth2Client, User } from '$lib/server/drizzle';
import { OAuth2Clients } from '$lib/server/oauth2'; import { OAuth2Clients } from '$lib/server/oauth2';
import { Users } from '$lib/server/users'; import { Users } from '$lib/server/users';
import { UsersAdmin } from '$lib/server/users/admin'; import { hasPrivileges } from '$lib/utils';
import { error, redirect } from '@sveltejs/kit'; import { error, redirect } from '@sveltejs/kit';
interface PrivilegesRequest { interface PrivilegesRequest {
@ -12,15 +12,17 @@ interface PrivilegesRequest {
export const actions = { export const actions = {
privileges: async ({ locals, params: { uuid, user: userId }, request }) => { privileges: async ({ locals, params: { uuid, user: userId }, request }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [ const { currentUser, userSession } = await AdminUtils.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
}); });
@ -67,11 +69,13 @@ export const load = async ({ params: { uuid, user: userId }, parent }) => {
const currentUser = await Users.getBySession(user); const currentUser = await Users.getBySession(user);
AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2']]); AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2']]);
const fullPrivileges = hasPrivileges(user.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
}); });

View File

@ -34,6 +34,5 @@ export const GET = async ({ locals, url }) => {
await OAuth2Clients.addManager(client, inviter, currentUser); await OAuth2Clients.addManager(client, inviter, currentUser);
await UserTokens.remove(fetch); await UserTokens.remove(fetch);
console.log('?');
return ApiUtils.redirect(`/ssoadmin/oauth2/${client.client_id}`); return ApiUtils.redirect(`/ssoadmin/oauth2/${client.client_id}`);
}; };

View File

@ -1,7 +1,6 @@
import { AdminUtils } from '$lib/server/admin-utils'; import { AdminUtils } from '$lib/server/admin-utils';
import { Changesets } from '$lib/server/changesets.js'; import { Changesets } from '$lib/server/changesets.js';
import { OAuth2Clients } from '$lib/server/oauth2/index.js'; import { OAuth2Clients } from '$lib/server/oauth2/index.js';
import { UsersAdmin } from '$lib/server/users/admin';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
interface CreateClientRequest { interface CreateClientRequest {
@ -12,8 +11,8 @@ interface CreateClientRequest {
export const actions = { export const actions = {
default: async ({ locals, request }) => { default: async ({ locals, request }) => {
const { currentUser } = await UsersAdmin.getActionUser(locals, [ const { currentUser } = await AdminUtils.getActionUser(locals, [
['admin:oauth2', 'self:oauth2'] ['admin:oauth2', 'self:oauth2:create']
]); ]);
const body = await request.formData(); const body = await request.formData();
@ -42,7 +41,7 @@ export const actions = {
export const load = async ({ parent }) => { export const load = async ({ parent }) => {
const { user } = await parent(); const { user } = await parent();
AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2']]); AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2:create']]);
return {}; return {};
}; };

View File

@ -19,7 +19,7 @@ interface UpdateRequest {
export const actions = { export const actions = {
removeOtp: async () => {}, removeOtp: async () => {},
removeAvatar: async ({ locals, params: { uuid } }) => { removeAvatar: async ({ locals, params: { uuid } }) => {
await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']); await AdminUtils.getActionUser(locals, ['admin', 'admin:user']);
const targetUser = await Users.getByUuid(uuid, false); const targetUser = await Users.getByUuid(uuid, false);
if (!targetUser) { if (!targetUser) {
@ -31,7 +31,7 @@ export const actions = {
return { errors: [] }; return { errors: [] };
}, },
deleteInfo: async ({ locals, params: { uuid } }) => { deleteInfo: async ({ locals, params: { uuid } }) => {
await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']); await AdminUtils.getActionUser(locals, ['admin', 'admin:user']);
const targetUser = await Users.getByUuid(uuid, false); const targetUser = await Users.getByUuid(uuid, false);
if (!targetUser || !!targetUser.activated) { if (!targetUser || !!targetUser.activated) {
@ -57,7 +57,7 @@ export const actions = {
return { errors: [] }; return { errors: [] };
}, },
email: async ({ locals, params: { uuid }, url }) => { email: async ({ locals, params: { uuid }, url }) => {
await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']); await AdminUtils.getActionUser(locals, ['admin', 'admin:user']);
const type = url.searchParams.get('type') as 'password' | 'activate'; const type = url.searchParams.get('type') as 'password' | 'activate';
if (!type) { if (!type) {
@ -85,7 +85,7 @@ export const actions = {
return { errors: [] }; return { errors: [] };
}, },
update: async ({ locals, params: { uuid }, request }) => { update: async ({ locals, params: { uuid }, request }) => {
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [ const { currentUser, userSession } = await AdminUtils.getActionUser(locals, [
'admin', 'admin',
'admin:user' 'admin:user'
]); ]);

View File

@ -11,6 +11,8 @@
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'; import { PUBLIC_SITE_NAME } from '$env/static/public';
import ActionButton from '$lib/components/ActionButton.svelte';
import Alert from '$lib/components/Alert.svelte';
export let data: PageData; export let data: PageData;
export let form: ActionData; export let form: ActionData;
@ -30,13 +32,14 @@
<AvatarCard src={`/api/avatar/${data.details.uuid}?t=${data.renderrt}`}> <AvatarCard src={`/api/avatar/${data.details.uuid}?t=${data.renderrt}`}>
<ColumnView> <ColumnView>
{#if data.details.pictureId} {#if data.details.pictureId}
<form action="?/removeAvatar" method="POST"> <ActionButton action="?/removeAvatar">{$t('account.avatar.remove')}</ActionButton>
<Button type="submit" variant="link">{$t('account.avatar.remove')}</Button>
</form>
{/if} {/if}
</ColumnView> </ColumnView>
</AvatarCard> </AvatarCard>
{#if form?.errors && !form.errors.length}
<Alert type="success">{$t('admin.oauth2.success')}</Alert>
{/if}
<FormErrors errors={form?.errors || []} prefix="admin.users.errors" /> <FormErrors errors={form?.errors || []} prefix="admin.users.errors" />
<form action="?/update" method="POST"> <form action="?/update" method="POST">
@ -81,20 +84,15 @@
<h3>{$t('account.otp.title')}</h3> <h3>{$t('account.otp.title')}</h3>
<p>{$t(`account.otp.${data.details.otpEnabled ? 'enabled' : 'unavailable'}`)}</p> <p>{$t(`account.otp.${data.details.otpEnabled ? 'enabled' : 'unavailable'}`)}</p>
{#if data.details.otpEnabled} {#if data.details.otpEnabled}
<form action="?/removeOtp" method="POST"> <ActionButton action="?/removeOtp">{$t('admin.users.deactivateOtp')}</ActionButton>
<Button type="submit" variant="link">{$t('admin.users.deactivateOtp')}</Button>
</form>
{/if} {/if}
<h3>{$t('admin.users.actions')}</h3> <h3>{$t('admin.users.actions')}</h3>
{#if data.details.activated} {#if data.details.activated}
<form action="?/email&type=password" method="POST"> <ActionButton action="?/email&type=password">{$t('admin.users.passwordEmail')}</ActionButton>
<Button type="submit" variant="link">{$t('admin.users.passwordEmail')}</Button>
</form>
{:else} {:else}
<form action="?/email&type=activate" method="POST"> <ActionButton action="?/email&type=activate">{$t('admin.users.activationEmail')}</ActionButton
<Button type="submit" variant="link">{$t('admin.users.activationEmail')}</Button> >
</form>
<form action="?/deleteInfo" method="POST"> <form action="?/deleteInfo" method="POST">
<Button type="submit" variant="link">{$t('admin.users.deleteInfo')}</Button> <Button type="submit" variant="link">{$t('admin.users.deleteInfo')}</Button>
- <span>{$t('admin.users.deleteInfoHint')}</span> - <span>{$t('admin.users.deleteInfoHint')}</span>