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

@ -1,130 +1,132 @@
{ {
"title": "Admin", "title": "Admin",
"menu": { "menu": {
"users": "Users", "users": "Users",
"oauth2": "OAuth2 applications", "oauth2": "OAuth2 applications",
"audit": "Audit logs" "audit": "Audit logs"
}, },
"users": { "users": {
"title": "Users", "title": "Users",
"uuid": "UUID", "uuid": "UUID",
"email": "Email", "email": "Email",
"privileges": "Privileges", "privileges": "Privileges",
"actions": "Account actions", "actions": "Account actions",
"activated": "Activated", "activated": "Activated",
"registered": "Registered", "registered": "Registered",
"deactivate": "Deactivate account", "deactivate": "Deactivate account",
"deactivateOtp": "Remove two-factor authentication", "deactivateOtp": "Remove two-factor authentication",
"deleteInfo": "Delete account information", "deleteInfo": "Delete account information",
"deleteInfoHint": "All personalized information will be deleted. The account will remain in the database as an UUID stub. This action is irreversible.", "deleteInfoHint": "All personalized information will be deleted. The account will remain in the database as an UUID stub. This action is irreversible.",
"passwordEmail": "Send password email", "passwordEmail": "Send password email",
"activationEmail": "Send activation email", "activationEmail": "Send activation email",
"errors": { "errors": {
"invalidUuid": "Invalid user or impossible action", "invalidUuid": "Invalid user or impossible action",
"invalidEmailType": "Invalid email type", "invalidEmailType": "Invalid email type",
"unauthorized": "Unauthorized changes", "unauthorized": "Unauthorized changes",
"lockout": "You cannot lock yourself out!", "lockout": "You cannot lock yourself out!",
"invalidDisplayName": "Invalid display name", "invalidDisplayName": "Invalid display name",
"invalidEmail": "Invalid email address" "invalidEmail": "Invalid email address"
} }
}, },
"oauth2": { "oauth2": {
"title": "OAuth2 applications", "title": "OAuth2 applications",
"new": "Create a new application", "new": "Create a new application",
"clientTitle": "Application name", "clientTitle": "Application name",
"clientId": "Client ID", "clientId": "Client ID",
"clientSecret": "Client secret", "clientSecret": "Client secret",
"description": "Application description", "description": "Application description",
"reveal": "Reveal secret", "reveal": "Reveal secret",
"regenerate": "Regenerate secret", "regenerate": "Regenerate secret",
"activated": "Activated", "activated": "Activated",
"verified": "Official", "verified": "Official",
"scopes": "Available scopes", "scopes": "Available scopes",
"scopesHint": "The level of access to information you will be needing for this application.", "scopesHint": "The level of access to information you will be needing for this application.",
"grants": "Available grant types", "grants": "Available grant types",
"grantsHint": "The OAuth2 authorization flows you will be using with this application.", "grantsHint": "The OAuth2 authorization flows you will be using with this application.",
"created": "Created at", "created": "Created at",
"owner": "Created by", "owner": "Created by",
"ownerMe": "that's you!", "ownerMe": "that's you!",
"actions": "Actions", "actions": "Actions",
"delete": "Delete application permanently", "delete": "Delete application permanently",
"avatar": { "avatar": {
"title": "Application icon", "title": "Application icon",
"remove": "Delete application icon", "remove": "Delete application icon",
"change": "Change application icon" "change": "Change application icon"
}, },
"authorizations": "Authorized users", "authorizations": "Authorized users",
"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.",
"privileges": { "success": "Success!",
"title": "Application privileges", "privileges": {
"name": "Privilege name", "title": "Application privileges",
"nameHint": "An unique prefix \"{{prefix}}\" will be prepended automatically. English alphabet and ._-: characters only.", "name": "Privilege name",
"add": "Add a new privilege", "nameHint": "An unique prefix \"{{prefix}}\" will be prepended automatically. English alphabet and ._-: characters only.",
"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!", "add": "Add a new privilege",
"remove": "Remove", "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!",
"manage": "Manage user privileges", "remove": "Remove",
"new": "Add a new privilege", "manage": "Manage user privileges",
"edit": "Edit privileges" "new": "Add a new privilege",
}, "edit": "Edit privileges"
"urls": { },
"title": "Application URLs", "urls": {
"new": "Add a new URL", "title": "Application URLs",
"type": "URL type", "new": "Add a new URL",
"url": "URL", "type": "URL type",
"types": { "url": "URL",
"website": "Website", "types": {
"privacy": "Privacy Policy", "website": "Website",
"terms": "Terms of Service", "privacy": "Privacy Policy",
"redirect_uri": "Redirect URI" "terms": "Terms of Service",
}, "redirect_uri": "Redirect URI"
"add": "Add URL" },
}, "add": "Add URL"
"apis": { },
"title": "OAuth2 APIs", "apis": {
"authorize": "OAuth2 Authorization endpoint", "title": "OAuth2 APIs",
"token": "OAuth2 Token endpoint", "authorize": "OAuth2 Authorization endpoint",
"introspect": "OAuth2 Introspection endpoint", "token": "OAuth2 Token endpoint",
"userinfo": "User information endpoint (Bearer)", "introspect": "OAuth2 Introspection endpoint",
"openid": "OpenID Connect configuration" "userinfo": "User information endpoint (Bearer)",
}, "openid": "OpenID Connect configuration"
"grantTexts": { },
"authorization_code": "Authorization code", "grantTexts": {
"client_credentials": "Client credentials", "authorization_code": "Authorization code",
"refresh_token": "Refresh token", "client_credentials": "Client credentials",
"implicit": "Implicit token", "refresh_token": "Refresh token",
"id_token": "ID token (OpenID Connect)" "implicit": "Implicit token",
}, "id_token": "ID token (OpenID Connect)"
"scopeTexts": { },
"picture": "Access profile picture URL", "scopeTexts": {
"profile": "Basic profile information", "picture": "Access profile picture URL",
"email": "Access user email address", "profile": "Basic profile information",
"privileges": "Access user privilege list", "email": "Access user email address",
"management": "Manage your application", "privileges": "Access user privilege list",
"account": "Change user account settings", "management": "Manage your application",
"openid": "Get an ID token JWT (OpenID Connect)" "account": "Change user account settings",
}, "openid": "Get an ID token JWT (OpenID Connect)"
"managers": { },
"title": "Application members", "managers": {
"hint": "These users can edit this application just like you can, except that they cannot regenerate the secret or delete the application. Please note that they must have an active account on {{siteName}}, but not necessarily with the provided address.", "title": "Application members",
"add": "Invite a new member", "hint": "These users can edit this application just like you can, except that they cannot regenerate the secret or delete the application. Please note that they must have an active account on {{siteName}}, but not necessarily with the provided address.",
"invite": "Invite" "add": "Invite a new member",
}, "invite": "Invite"
"errors": { },
"noRedirect": "At least one Redirect URI is required for you to be able to use this application!", "errors": {
"forbidden": "This action is forbidden for this user.", "noRedirect": "At least one Redirect URI is required for you to be able to use this application!",
"invalidTitle": "Name must be between 3 and 32 characters long.", "forbidden": "This action is forbidden for this user.",
"invalidDescription": "Description must be at most 1000 characters long.", "invalidTitle": "Name must be between 3 and 32 characters long.",
"deleteActivated": "Cannot delete an active application. Please deactivate it first.", "invalidDescription": "Description must be at most 1000 characters long.",
"invalidUrlId": "Invalid URL ID for deletion.", "deleteActivated": "Cannot delete an active application. Please deactivate it first.",
"invalidUrlType": "Invalid URL type provided.", "invalidUrlId": "Invalid URL ID for deletion.",
"invalidUrl": "Invalid URL provided.", "invalidUrlType": "Invalid URL type provided.",
"invalidPrivilegeId": "Invalid privilege ID for deletion.", "invalidUrl": "Invalid URL provided.",
"invalidPrivilege": "Invalid privilege provided.", "invalidPrivilegeId": "Invalid privilege ID for deletion.",
"invalidEmail": "Invalid email address.", "invalidPrivilege": "Invalid privilege provided.",
"emailExists": "This email address is already added.", "invalidEmail": "Invalid email address.",
"noFile": "Please upload a file first." "emailExists": "This email address is already added.",
} "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>
<h1>{PUBLIC_SITE_NAME}</h1> <TitleRow>
<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}
<Button type="submit" variant="primary" disabled={submitted} <ButtonRow>
>{$t('account.submit')}</Button <Button type="submit" variant="primary" disabled={submitted}
> >{$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>
<Button type="submit" variant="primary" disabled={submitted} <ButtonRow>
>{$t('account.register.submit')}</Button <Button type="submit" variant="primary" disabled={submitted}
> >{$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>
<a href="oauth2/new">{$t('admin.oauth2.new')}</a> {#if data.createPrivileges}
<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,25 +30,49 @@ interface InviteRequest {
email: string; email: string;
} }
const oneOffLimiter = new RateLimiter({
IP: [1, 'm']
});
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']
]);
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');
}
return { currentUser, userSession, fullPrivileges, details: details as OAuth2Client };
};
export const actions = { export const actions = {
/**
* Update the OAuth2 Client general information.
*/
update: async ({ locals, request, params: { uuid } }) => { update: 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 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>(
@ -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>