Retheme, organize, comment
This commit is contained in:
parent
dc9be2016b
commit
77e2b49aa6
32
src/app.css
32
src/app.css
@ -1,34 +1,11 @@
|
||||
@import url('./colors.css');
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
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 {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
@ -40,11 +17,12 @@ body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--in-normalized-background);
|
||||
background-image: url('/background.jpg');
|
||||
background-image: var(--in-background-image);
|
||||
background-attachment: fixed;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
@ -72,7 +50,7 @@ a {
|
||||
|
||||
a[target='_blank']::after {
|
||||
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;
|
||||
height: 0.95rem;
|
||||
display: inline-block;
|
||||
|
122
src/colors.css
Normal file
122
src/colors.css
Normal 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);
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import { AUTO_MIGRATE, SESSION_SECRET } from '$env/static/private';
|
||||
import { db } from '$lib/server/drizzle';
|
||||
import { runSeeds } from '$lib/server/drizzle/seeds';
|
||||
import { migrate } from 'drizzle-orm/mysql2/migrator';
|
||||
import { handleSession } from 'svelte-kit-cookie-session';
|
||||
|
||||
@ -7,6 +8,8 @@ if (AUTO_MIGRATE === 'true') {
|
||||
await migrate(db, { migrationsFolder: './migrations' });
|
||||
}
|
||||
|
||||
await runSeeds();
|
||||
|
||||
export const handle = handleSession({
|
||||
secret: SESSION_SECRET
|
||||
});
|
||||
|
18
src/lib/components/ActionButton.svelte
Normal file
18
src/lib/components/ActionButton.svelte
Normal 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>
|
@ -18,7 +18,7 @@
|
||||
background-color: var(--in-alert-color);
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
padding: 24px 28px;
|
||||
padding: 16px 18px;
|
||||
font-size: 1.15rem;
|
||||
|
||||
& p {
|
||||
|
@ -17,7 +17,7 @@
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--in-text-color);
|
||||
color: var(--in-link-color);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
|
||||
@ -32,11 +32,21 @@
|
||||
|
||||
.btn-default,
|
||||
.btn-primary {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
border: 2px solid #ddd;
|
||||
background-color: var(--in-button-primary-background);
|
||||
color: var(--in-button-primary-color);
|
||||
border: var(--in-button-primary-border);
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
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>
|
||||
|
@ -1,8 +1,6 @@
|
||||
<script>
|
||||
import { t } from '$lib/i18n';
|
||||
import Button from './Button.svelte';
|
||||
import ActionButton from './ActionButton.svelte';
|
||||
</script>
|
||||
|
||||
<form action="/account?/logout" method="POST">
|
||||
<Button type="submit" variant="link">{$t('account.logout')}</Button>
|
||||
</form>
|
||||
<ActionButton action="/account?/logout">{$t('account.logout')}</ActionButton>
|
||||
|
@ -2,6 +2,7 @@
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let showModal: boolean;
|
||||
export let dismissable = true;
|
||||
let dialog: HTMLDialogElement;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
@ -17,7 +18,7 @@
|
||||
showModal = false;
|
||||
dispatch('close');
|
||||
}}
|
||||
on:click|self={() => dialog.close()}
|
||||
on:click|self={() => dismissable && dialog.close()}
|
||||
>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div on:click|stopPropagation>
|
||||
@ -43,6 +44,7 @@
|
||||
border-radius: 0.2em;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--in-modal-text-color);
|
||||
background-color: var(--in-modal-background);
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@
|
||||
href={`?page=${pageNum - 1}`}
|
||||
tabindex={firstPage ? -1 : 0}
|
||||
aria-label={$t('common.previous')}
|
||||
aria-disabled={firstPage}><<</a
|
||||
aria-disabled={firstPage}><</a
|
||||
>
|
||||
|
||||
{#each pageButtons as buttonNumber}
|
||||
@ -35,7 +35,7 @@
|
||||
tabindex={lastPage ? -1 : 0}
|
||||
href={`?page=${pageNum + 1}`}
|
||||
aria-label={$t('common.next')}
|
||||
aria-disabled={lastPage}>>></a
|
||||
aria-disabled={lastPage}>></a
|
||||
>
|
||||
</nav>
|
||||
|
||||
@ -49,8 +49,8 @@
|
||||
.page-button {
|
||||
--in-page-button-size: 28px;
|
||||
display: block;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
background-color: var(--in-button-primary-background);
|
||||
color: var(--in-button-primary-color);
|
||||
min-width: var(--in-page-button-size);
|
||||
height: var(--in-page-button-size);
|
||||
line-height: var(--in-page-button-size);
|
||||
@ -60,14 +60,14 @@
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background-color: #ececec;
|
||||
background-color: var(--in-button-primary-hover-background);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
background-color: #ececec;
|
||||
color: #616161;
|
||||
background-color: var(--in-button-primary-disabled-background);
|
||||
color: var(--in-button-primary-disabled-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -59,9 +59,9 @@
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 8px;
|
||||
background-color: #fff;
|
||||
background-color: var(--ina-card-background);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: var(--ina-card-shadow);
|
||||
flex-grow: 1;
|
||||
|
||||
transition: transform 100ms linear;
|
||||
|
@ -18,8 +18,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: #00aaff;
|
||||
background: linear-gradient(180deg, #00aaff 0%, #005bff 100%);
|
||||
background: var(--ina-header-color);
|
||||
}
|
||||
|
||||
.admin-user {
|
||||
@ -27,9 +26,9 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px 4px 4px;
|
||||
background-color: #004edf;
|
||||
background-color: var(--ina-header-tag-color);
|
||||
border-radius: 40px;
|
||||
color: #fff;
|
||||
color: var(--ina-header-tag-text-color);
|
||||
|
||||
& .admin-user-avatar {
|
||||
width: 32px;
|
||||
@ -41,10 +40,10 @@
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
color: var(--ina-header-link-color);
|
||||
|
||||
&:visited {
|
||||
color: #fff;
|
||||
color: var(--ina-header-link-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -47,11 +47,11 @@
|
||||
.admin-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #dddddd;
|
||||
background-color: var(--ina-sidebar-color);
|
||||
height: 100%;
|
||||
max-width: 240px;
|
||||
width: 100%;
|
||||
border-right: 2px solid #b4b4b4;
|
||||
border-right: 2px solid var(--ina-sidebar-border-color);
|
||||
|
||||
& > nav {
|
||||
margin-top: 16px;
|
||||
@ -69,11 +69,11 @@
|
||||
text-decoration: none;
|
||||
|
||||
&.active {
|
||||
background-color: #c7c7c7;
|
||||
background-color: var(--ina-sidebar-link-active-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #e4e4e4;
|
||||
background-color: var(--ina-sidebar-link-hover-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -82,6 +82,6 @@
|
||||
|
||||
.admin-sidebar,
|
||||
.admin-sidebar :global(a) {
|
||||
color: #000;
|
||||
color: var(--ina-sidebar-link-text-color);
|
||||
}
|
||||
</style>
|
||||
|
@ -43,9 +43,9 @@
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 8px;
|
||||
background-color: #fff;
|
||||
background-color: var(--ina-card-background);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: var(--ina-card-shadow);
|
||||
flex-grow: 1;
|
||||
|
||||
transition: transform 100ms linear;
|
||||
|
@ -11,6 +11,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.page-inner {
|
||||
@ -18,6 +19,7 @@
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
background: var(--in-container-background);
|
||||
padding: 40px;
|
||||
max-width: 1080px;
|
||||
width: 100%;
|
||||
@ -25,7 +27,6 @@
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
@ -20,6 +20,7 @@
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
background: var(--in-container-background);
|
||||
padding: 40px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
|
@ -8,7 +8,7 @@
|
||||
:global(input) {
|
||||
background-color: var(--in-input-background);
|
||||
color: var(--in-input-color);
|
||||
border: 2px solid var(--in-input-border-color);
|
||||
border: var(--in-input-border);
|
||||
|
||||
&:not([type]),
|
||||
&[type='text'],
|
||||
@ -17,7 +17,7 @@
|
||||
&[type='email'] {
|
||||
padding: 8px;
|
||||
font-size: 1rem;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--in-focus-outline);
|
||||
@ -34,6 +34,11 @@
|
||||
border-color: var(--in-input-border-color-invalid);
|
||||
}
|
||||
}
|
||||
|
||||
&[type='file'] {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-control > :global(label) {
|
||||
|
@ -57,6 +57,7 @@
|
||||
"authorizationsHint": "These users have authorized this application at least once. You may assign application privileges to each user individually.",
|
||||
"revoked": "This authorization has been revoked by the user",
|
||||
"noAuthorizations": "There are no authorizations on record for this application.",
|
||||
"success": "Success!",
|
||||
"privileges": {
|
||||
"title": "Application privileges",
|
||||
"name": "Privilege name",
|
||||
@ -124,7 +125,8 @@
|
||||
"invalidPrivilege": "Invalid privilege provided.",
|
||||
"invalidEmail": "Invalid email address.",
|
||||
"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!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"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 <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 <a href=\"https://git.icynet.eu/IcyNetwork/sso-core\" target=\"_blank\">completely open source</a> and can be audited by anyone.",
|
||||
"submit": "Submit",
|
||||
"cancel": "Cancel",
|
||||
"manage": "Manage",
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { UserSession } from './users';
|
||||
import { Users, type UserSession } from './users';
|
||||
import { hasPrivileges, type RequiredPrivileges } from '$lib/utils';
|
||||
|
||||
export class AdminUtils {
|
||||
@ -8,4 +8,23 @@ export class AdminUtils {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
9
src/lib/server/drizzle/seeds/index.ts
Normal file
9
src/lib/server/drizzle/seeds/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import privilegesSeed from './privileges';
|
||||
|
||||
const seeds = [privilegesSeed];
|
||||
|
||||
export const runSeeds = async () => {
|
||||
for (const fn of seeds) {
|
||||
await fn();
|
||||
}
|
||||
};
|
28
src/lib/server/drizzle/seeds/privileges.ts
Normal file
28
src/lib/server/drizzle/seeds/privileges.ts
Normal 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 });
|
||||
}
|
||||
}
|
@ -11,7 +11,7 @@ ${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}
|
||||
|
||||
@ -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><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>
|
||||
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
} from '$lib/server/drizzle';
|
||||
import { Emails, OAuth2InvitationEmail } from '$lib/server/email';
|
||||
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 { and, count, eq, like, or, sql } from 'drizzle-orm';
|
||||
|
||||
@ -64,6 +64,9 @@ export class OAuth2Clients {
|
||||
'openid'
|
||||
];
|
||||
|
||||
// Non-administrator capabilities
|
||||
public static implicitGrantTypes = ['id_token', 'implicit'];
|
||||
public static userSetGrants = ['authorization_code', 'client_credentials', 'refresh_token'];
|
||||
public static userSetScopes = [
|
||||
'profile',
|
||||
'picture',
|
||||
@ -72,12 +75,6 @@ export class OAuth2Clients {
|
||||
'management',
|
||||
'openid'
|
||||
];
|
||||
public static userSetGrants = [
|
||||
'authorization_code',
|
||||
'client_credentials',
|
||||
'refresh_token',
|
||||
'id_token'
|
||||
];
|
||||
|
||||
public static describedScopes = ['email', 'picture', 'account'];
|
||||
public static alwaysPresentScopes = ['profile'];
|
||||
@ -317,7 +314,7 @@ export class OAuth2Clients {
|
||||
client_id: uid,
|
||||
client_secret: secret,
|
||||
grants: 'authorization_code',
|
||||
scope: 'profile',
|
||||
scope: OAuth2Clients.joinScope(OAuth2Clients.alwaysPresentScopes),
|
||||
ownerId: subject.id,
|
||||
created_at: new Date(),
|
||||
activated: 1,
|
||||
@ -382,6 +379,13 @@ export class OAuth2Clients {
|
||||
}
|
||||
|
||||
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({
|
||||
clientId: client.id,
|
||||
userId: subject.id,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { count, eq, like, or, sql } from 'drizzle-orm';
|
||||
import { asc, count, eq, like, or, sql } from 'drizzle-orm';
|
||||
import {
|
||||
db,
|
||||
privilege,
|
||||
@ -8,16 +8,17 @@ import {
|
||||
type User
|
||||
} from '../drizzle';
|
||||
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'> {
|
||||
privileges: Privilege[];
|
||||
}
|
||||
|
||||
export class UsersAdmin {
|
||||
/**
|
||||
* Map a list of users with their privileges.
|
||||
* @param junkList User-privilege amalgamation
|
||||
* @returns List of users
|
||||
*/
|
||||
static mergeUserResponse(
|
||||
junkList: {
|
||||
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({
|
||||
filter,
|
||||
offset = 0,
|
||||
@ -69,14 +75,26 @@ export class UsersAdmin {
|
||||
.from(user)
|
||||
.where(searchExpression);
|
||||
|
||||
const junkList = await db
|
||||
.select()
|
||||
const baseQuery = db
|
||||
.select({ id: user.id })
|
||||
.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(privilege, eq(userPrivilegesPrivilege.privilegeId, privilege.id))
|
||||
.where(searchExpression)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
.where(searchExpression);
|
||||
|
||||
const meta: PaginationMeta = {
|
||||
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) {
|
||||
const junkList = await db
|
||||
.select()
|
||||
@ -102,17 +125,4 @@ export class UsersAdmin {
|
||||
const [userInfo] = UsersAdmin.mergeUserResponse(junkList);
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs';
|
||||
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
import { db, privilege, user, userPrivilegesPrivilege, type User } from '../drizzle';
|
||||
import type { UserSession } from './types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { CryptoUtils } from '../crypto-utils';
|
||||
import { EMAIL_ENABLED } from '$env/static/private';
|
||||
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';
|
||||
|
||||
export class Users {
|
||||
/**
|
||||
* Get user by their primary key ID.
|
||||
* @param id User ID
|
||||
* @returns User
|
||||
*/
|
||||
static async getById(id: number): Promise<User | undefined> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
@ -19,6 +24,11 @@ export class Users {
|
||||
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> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
@ -28,15 +38,30 @@ export class Users {
|
||||
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> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.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);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an user by their session cookie.
|
||||
* @param session Session from cookie
|
||||
* @returns User
|
||||
*/
|
||||
static async getBySession(session?: UserSession): Promise<User | undefined> {
|
||||
if (!session) return undefined;
|
||||
const [result] = await db
|
||||
@ -47,18 +72,39 @@ export class Users {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an user.
|
||||
* @param subject User to update
|
||||
* @param fields Fields to set
|
||||
*/
|
||||
static async update(subject: User, fields: Partial<User>) {
|
||||
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> {
|
||||
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> {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session cookie from user information.
|
||||
* @param user User
|
||||
* @returns Cookie
|
||||
*/
|
||||
static async toSession(user: User): Promise<UserSession> {
|
||||
return {
|
||||
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) {
|
||||
const currentUser = await Users.getBySession(locals.session.data?.user);
|
||||
if (!currentUser) {
|
||||
@ -77,6 +129,12 @@ export class Users {
|
||||
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) {
|
||||
return !(
|
||||
await db
|
||||
@ -91,6 +149,11 @@ export class Users {
|
||||
)?.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user information by activation token.
|
||||
* @param token Activation token
|
||||
* @returns User information
|
||||
*/
|
||||
static async getActivationToken(token: string) {
|
||||
const returnedToken = await UserTokens.getByToken(token, 'activation');
|
||||
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) {
|
||||
await db
|
||||
.update(user)
|
||||
@ -114,6 +182,11 @@ export class Users {
|
||||
await UserTokens.remove(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user.
|
||||
* @param details User details
|
||||
* @returns New User
|
||||
*/
|
||||
static async register({
|
||||
username,
|
||||
displayName,
|
||||
@ -149,6 +222,10 @@ export class Users {
|
||||
return newUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an activation email to an user.
|
||||
* @param user User
|
||||
*/
|
||||
static async sendRegistrationEmail(user: User) {
|
||||
const token = await UserTokens.create(
|
||||
'activation',
|
||||
@ -172,6 +249,10 @@ export class Users {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a password reset email to an user.
|
||||
* @param user User
|
||||
*/
|
||||
static async sendPasswordEmail(user: User) {
|
||||
const token = await UserTokens.create('password', new Date(Date.now() + 3600 * 1000), user.id);
|
||||
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) {
|
||||
const token = await UserTokens.create(
|
||||
'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) {
|
||||
return await db
|
||||
.select()
|
||||
@ -222,6 +312,12 @@ export class Users {
|
||||
.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) {
|
||||
const list = await db
|
||||
.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) {
|
||||
// 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
|
||||
.select({
|
||||
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) {
|
||||
const [name, domain] = email.split('@');
|
||||
const namePart = `${name.charAt(0)}${''.padStart(name.length - 2, '*')}${name.charAt(name.length - 1)}`;
|
||||
|
@ -17,6 +17,7 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||
import FormErrors from '$lib/components/form/FormErrors.svelte';
|
||||
import TitleRow from '$lib/components/container/TitleRow.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
@ -58,7 +59,12 @@
|
||||
</svelte:head>
|
||||
|
||||
<MainContainer>
|
||||
<TitleRow>
|
||||
<h1>{PUBLIC_SITE_NAME}</h1>
|
||||
|
||||
<LogoutButton />
|
||||
</TitleRow>
|
||||
|
||||
<SplitView>
|
||||
<div>
|
||||
<h2>{$t('account.title')}</h2>
|
||||
@ -160,8 +166,6 @@
|
||||
<Button variant="primary" type="submit">{$t('account.submit')}</Button>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
|
||||
<LogoutButton />
|
||||
</ViewColumn>
|
||||
</div>
|
||||
|
||||
|
@ -4,6 +4,7 @@
|
||||
import { PUBLIC_SITE_NAME } from '$env/static/public';
|
||||
import Alert from '$lib/components/Alert.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 SideContainer from '$lib/components/container/SideContainer.svelte';
|
||||
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||
@ -88,9 +89,12 @@
|
||||
<input type="email" name="email" id="password-email" autocomplete="email" />
|
||||
</FormControl>
|
||||
{/if}
|
||||
<ButtonRow>
|
||||
<Button type="submit" variant="primary" disabled={submitted}
|
||||
>{$t('account.submit')}</Button
|
||||
>
|
||||
<div><a href="/login">{$t('account.login.title')}</a></div>
|
||||
</ButtonRow>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
{/if}
|
||||
|
@ -12,6 +12,7 @@
|
||||
import FormSection from '$lib/components/form/FormSection.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import FormErrors from '$lib/components/form/FormErrors.svelte';
|
||||
import ButtonRow from '$lib/components/container/ButtonRow.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
@ -120,12 +121,14 @@
|
||||
</FormControl>
|
||||
</FormSection>
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" variant="primary" disabled={submitted}
|
||||
>{$t('account.register.submit')}</Button
|
||||
>
|
||||
<div><a href="/login">{$t('account.login.title')}</a></div>
|
||||
</ButtonRow>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
{/if}
|
||||
<div><a href="/login">{$t('account.login.title')}</a></div>
|
||||
</ColumnView>
|
||||
</SideContainer>
|
||||
|
@ -1,8 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import AdminHeader from '$lib/components/admin/AdminHeader.svelte';
|
||||
import AdminSidebar from '$lib/components/admin/AdminSidebar.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import type { PageData } from './$types';
|
||||
export let data: PageData;
|
||||
|
||||
let container: HTMLElement;
|
||||
|
||||
// Reset container scroll.
|
||||
afterNavigate(async ({ type }) => {
|
||||
if (type !== 'link') return;
|
||||
await tick();
|
||||
container?.scrollTo(0, 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="admin-wrapper">
|
||||
@ -11,19 +22,13 @@
|
||||
<div class="sidebar-wrapper">
|
||||
<AdminSidebar user={data.user} />
|
||||
|
||||
<main>
|
||||
<main bind:this={container}>
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-wrapper {
|
||||
--in-text-color: #000;
|
||||
--in-link-color: #000;
|
||||
--in-error-color: #ff8080;
|
||||
}
|
||||
|
||||
.admin-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -40,8 +45,8 @@
|
||||
& > main {
|
||||
overflow: auto;
|
||||
flex-grow: 1;
|
||||
background-color: #ececec;
|
||||
color: #000;
|
||||
background-color: var(--ina-background-color);
|
||||
color: var(--in-text-color);
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ export const load = async ({ parent, url }) => {
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
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, {
|
||||
filter,
|
||||
@ -39,6 +40,7 @@ export const load = async ({ parent, url }) => {
|
||||
|
||||
return {
|
||||
fullPrivileges,
|
||||
createPrivileges,
|
||||
...data
|
||||
};
|
||||
};
|
||||
|
@ -18,7 +18,9 @@
|
||||
|
||||
<TitleRow>
|
||||
<h1>{$t('admin.oauth2.title')}</h1>
|
||||
{#if data.createPrivileges}
|
||||
<a href="oauth2/new">{$t('admin.oauth2.new')}</a>
|
||||
{/if}
|
||||
</TitleRow>
|
||||
|
||||
<ColumnView>
|
||||
|
@ -5,10 +5,10 @@ import type { OAuth2Client, User } from '$lib/server/drizzle';
|
||||
import { OAuth2ClientURLType, OAuth2Clients } from '$lib/server/oauth2';
|
||||
import { Uploads } from '$lib/server/upload.js';
|
||||
import { Users } from '$lib/server/users';
|
||||
import { UsersAdmin } from '$lib/server/users/admin';
|
||||
import { hasPrivileges } from '$lib/utils';
|
||||
import { emailRegex, privilegeRegex } from '$lib/validators.js';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import { RateLimiter } from 'sveltekit-rate-limiter/server';
|
||||
|
||||
interface AddUrlRequest {
|
||||
type: OAuth2ClientURLType;
|
||||
@ -30,9 +30,23 @@ interface InviteRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
update: async ({ locals, request, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
|
||||
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']
|
||||
]);
|
||||
|
||||
@ -50,6 +64,16 @@ export const actions = {
|
||||
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 { title, description, activated, verified } = Changesets.take<UpdateRequest>(
|
||||
['title', 'description', 'activated', 'verified'],
|
||||
@ -71,7 +95,7 @@ export const actions = {
|
||||
return fail(403, { errors: ['invalidDescription'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.update(details as OAuth2Client, {
|
||||
await OAuth2Clients.update(details, {
|
||||
title,
|
||||
description,
|
||||
verified: actuallyVerified,
|
||||
@ -80,24 +104,13 @@ export const actions = {
|
||||
|
||||
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 } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.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');
|
||||
}
|
||||
const { currentUser, fullPrivileges, details } = await getActionData(locals, uuid);
|
||||
|
||||
if (details.activated === 1) {
|
||||
return fail(400, { errors: ['deleteActivated'] });
|
||||
@ -107,85 +120,57 @@ export const actions = {
|
||||
return fail(403, { errors: ['forbidden'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.deleteClient(details as OAuth2Client);
|
||||
await OAuth2Clients.deleteClient(details);
|
||||
|
||||
return redirect(303, '/ssoadmin/oauth2');
|
||||
},
|
||||
regenerate: async ({ locals, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
|
||||
['admin:oauth2', 'self:oauth2']
|
||||
]);
|
||||
|
||||
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
|
||||
|
||||
/**
|
||||
* Regenerate OAuth2 Client secret key.
|
||||
*
|
||||
* Only the owner or admin can do it.
|
||||
*/
|
||||
regenerate: async (event) => {
|
||||
const {
|
||||
list: [details]
|
||||
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
|
||||
clientId: uuid,
|
||||
listAll: fullPrivileges,
|
||||
omitSecret: false
|
||||
});
|
||||
locals,
|
||||
params: { uuid }
|
||||
} = event;
|
||||
const { currentUser, fullPrivileges, details } = await getActionData(locals, uuid);
|
||||
|
||||
if (!details) {
|
||||
return error(404, 'Client not found');
|
||||
}
|
||||
|
||||
if (!fullPrivileges && !details.isOwner) {
|
||||
if (!fullPrivileges && details.ownerId !== currentUser.id) {
|
||||
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()
|
||||
});
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
/**
|
||||
* Remove an URL reference.
|
||||
*/
|
||||
removeUrl: async ({ locals, url, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.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');
|
||||
}
|
||||
const { details } = await getActionData(locals, uuid);
|
||||
|
||||
const id = Number(url.searchParams.get('id'));
|
||||
if (isNaN(id)) {
|
||||
return fail(400, { errors: ['invalidUrlId'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.deleteUrl(details as OAuth2Client, id);
|
||||
await OAuth2Clients.deleteUrl(details, id);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
/**
|
||||
* Add an URL reference.
|
||||
*/
|
||||
addUrl: async ({ locals, request, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.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');
|
||||
}
|
||||
const { details } = await getActionData(locals, uuid);
|
||||
|
||||
const body = await request.formData();
|
||||
const { type, url } = Changesets.take<AddUrlRequest>(['type', 'url'], body);
|
||||
@ -197,56 +182,30 @@ export const actions = {
|
||||
return fail(400, { errors: ['invalidUrl'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.addUrl(details as OAuth2Client, type, url);
|
||||
await OAuth2Clients.addUrl(details, type, url);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
/**
|
||||
* Remove a privilege reference.
|
||||
*/
|
||||
removePrivilege: async ({ locals, url, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.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');
|
||||
}
|
||||
const { details } = await getActionData(locals, uuid);
|
||||
|
||||
const id = Number(url.searchParams.get('id'));
|
||||
if (isNaN(id)) {
|
||||
return fail(400, { errors: ['invalidPrivilegeId'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.deletePrivilege(details as OAuth2Client, id);
|
||||
await OAuth2Clients.deletePrivilege(details, id);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
/**
|
||||
* Add a privilege reference.
|
||||
*/
|
||||
addPrivilege: async ({ locals, request, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.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');
|
||||
}
|
||||
const { details } = await getActionData(locals, uuid);
|
||||
|
||||
const body = await request.formData();
|
||||
const { name } = Changesets.take<AddPrivilegeRequest>(['name'], body);
|
||||
@ -255,28 +214,15 @@ export const actions = {
|
||||
return fail(400, { errors: ['invalidPrivilege'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.addPrivilege(details as OAuth2Client, name);
|
||||
await OAuth2Clients.addPrivilege(details, name);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
/**
|
||||
* Update the OAuth2 Client allowed grants list.
|
||||
*/
|
||||
grants: async ({ locals, request, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.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');
|
||||
}
|
||||
const { details, fullPrivileges } = await getActionData(locals, uuid);
|
||||
|
||||
const allowedGrants = fullPrivileges
|
||||
? OAuth2Clients.availableGrantTypes
|
||||
@ -289,30 +235,17 @@ export const actions = {
|
||||
(value, index, array) => allowedGrants.includes(value) && array.indexOf(value) === index
|
||||
);
|
||||
|
||||
await OAuth2Clients.update(details as OAuth2Client, {
|
||||
await OAuth2Clients.update(details, {
|
||||
grants: deduplicatedAllowedGrants.join(' ')
|
||||
});
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
/**
|
||||
* Update the OAuth2 Client allowed scopes list.
|
||||
*/
|
||||
scopes: async ({ locals, request, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.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');
|
||||
}
|
||||
const { details, fullPrivileges } = await getActionData(locals, uuid);
|
||||
|
||||
const allowedScopes = fullPrivileges
|
||||
? OAuth2Clients.availableScopes
|
||||
@ -325,30 +258,17 @@ export const actions = {
|
||||
(value, index, array) => allowedScopes.includes(value) && array.indexOf(value) === index
|
||||
);
|
||||
|
||||
await OAuth2Clients.update(details as OAuth2Client, {
|
||||
await OAuth2Clients.update(details, {
|
||||
scope: OAuth2Clients.joinScope(deduplicatedAllowedScopes)
|
||||
});
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
/**
|
||||
* Update the OAuth2 Client icon picture.
|
||||
*/
|
||||
avatar: async ({ request, locals, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.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');
|
||||
}
|
||||
const { currentUser, details } = await getActionData(locals, uuid);
|
||||
|
||||
const formData = Object.fromEntries(await request.formData());
|
||||
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 };
|
||||
|
||||
await Uploads.saveClientAvatar(details as OAuth2Client, currentUser, file);
|
||||
await Uploads.saveClientAvatar(details, currentUser, file);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
/**
|
||||
* Delete the OAuth2 Client icon picture.
|
||||
*/
|
||||
removeAvatar: async ({ locals, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
|
||||
['admin:oauth2', 'self:oauth2']
|
||||
]);
|
||||
const { details } = await getActionData(locals, uuid);
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
await Uploads.removeClientAvatar(details as OAuth2Client);
|
||||
await Uploads.removeClientAvatar(details);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
invite: async ({ locals, request, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
|
||||
['admin:oauth2', 'self:oauth2']
|
||||
]);
|
||||
|
||||
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
|
||||
|
||||
/**
|
||||
* Invite a new manager to the OAuth2 Client.
|
||||
* Managers can do most changes the client's owner can.
|
||||
*
|
||||
* Only the owner or admin can do it.
|
||||
*/
|
||||
invite: async (event) => {
|
||||
const {
|
||||
list: [details]
|
||||
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
|
||||
clientId: uuid,
|
||||
listAll: fullPrivileges,
|
||||
omitSecret: false
|
||||
});
|
||||
|
||||
if (!details) {
|
||||
return error(404, 'Client not found');
|
||||
}
|
||||
locals,
|
||||
request,
|
||||
params: { uuid }
|
||||
} = event;
|
||||
const { currentUser, details, fullPrivileges } = await getActionData(locals, uuid);
|
||||
|
||||
const body = await request.formData();
|
||||
const { email } = Changesets.take<InviteRequest>(['email'], body);
|
||||
@ -412,40 +314,41 @@ export const actions = {
|
||||
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())) {
|
||||
return fail(400, { errors: ['emailExists'] });
|
||||
}
|
||||
|
||||
if (!fullPrivileges && (await inviteLimiter.isLimited(event))) {
|
||||
return fail(429, { errors: ['tooManyTimes'] });
|
||||
}
|
||||
|
||||
await OAuth2Clients.sendManagerInvitationEmail(details as OAuth2Client, currentUser, email);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
/**
|
||||
* Remove a manager form the OAuth2 Client.
|
||||
*
|
||||
* Only the owner or admin can do it.
|
||||
*/
|
||||
removeManager: async ({ locals, url, params: { uuid } }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.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');
|
||||
}
|
||||
const { details, currentUser, fullPrivileges } = await getActionData(locals, uuid);
|
||||
|
||||
const id = Number(url.searchParams.get('id'));
|
||||
if (isNaN(id)) {
|
||||
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: [] };
|
||||
}
|
||||
@ -474,15 +377,29 @@ export const load = async ({ params: { uuid }, parent }) => {
|
||||
const users = await OAuth2Clients.getAuthorizedUsers(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 {
|
||||
users,
|
||||
managers,
|
||||
availableUrls: OAuth2Clients.availableUrlTypes,
|
||||
availablePrivileges: privileges,
|
||||
availableGrants: fullPrivileges
|
||||
? OAuth2Clients.availableGrantTypes
|
||||
: OAuth2Clients.userSetGrants,
|
||||
availableScopes: fullPrivileges ? OAuth2Clients.availableScopes : OAuth2Clients.userSetScopes,
|
||||
availableGrants,
|
||||
availableScopes,
|
||||
fullPrivileges,
|
||||
details
|
||||
};
|
||||
|
@ -15,6 +15,7 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import FormErrors from '$lib/components/form/FormErrors.svelte';
|
||||
import { PUBLIC_SITE_NAME, PUBLIC_URL } from '$env/static/public';
|
||||
import ActionButton from '$lib/components/ActionButton.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
@ -27,10 +28,10 @@
|
||||
|
||||
$: noRedirects = !data.details.urls.some(({ type }) => type === 'redirect_uri');
|
||||
$: availableUrls = data.availableUrls.filter((type) => {
|
||||
// Can have up to three redirect URIs, only one of other types
|
||||
// Can have up to five redirect URIs, only one of other types
|
||||
const countOfType = data.details.urls.filter(({ type: subType }) => type === subType).length;
|
||||
if (type === 'redirect_uri') {
|
||||
return countOfType < 3;
|
||||
return countOfType < 5;
|
||||
}
|
||||
return !countOfType;
|
||||
});
|
||||
@ -59,15 +60,16 @@
|
||||
>
|
||||
</div>
|
||||
{#if data.details.pictureId}
|
||||
<form action="?/removeAvatar" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.oauth2.avatar.remove')}</Button>
|
||||
</form>
|
||||
<ActionButton action="?/removeAvatar">{$t('admin.oauth2.avatar.remove')}</ActionButton>
|
||||
{/if}
|
||||
</ColumnView>
|
||||
</AvatarCard>
|
||||
|
||||
<form action="?/update" method="POST">
|
||||
<FormWrapper>
|
||||
{#if form?.errors && !form.errors.length}
|
||||
<Alert type="success">{$t('admin.oauth2.success')}</Alert>
|
||||
{/if}
|
||||
<FormErrors errors={form?.errors || []} prefix="admin.oauth2.errors" />
|
||||
<FormSection>
|
||||
<FormControl>
|
||||
@ -131,13 +133,9 @@
|
||||
<h2>{$t('admin.oauth2.actions')}</h2>
|
||||
{#if data.fullPrivileges || data.details.isOwner}
|
||||
{#if !data.details.activated}
|
||||
<form action="?/delete" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.oauth2.delete')}</Button>
|
||||
</form>
|
||||
<ActionButton action="?/delete">{$t('admin.oauth2.delete')}</ActionButton>
|
||||
{:else}
|
||||
<form action="?/regenerate" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.oauth2.regenerate')}</Button>
|
||||
</form>
|
||||
<ActionButton action="?/regenerate">{$t('admin.oauth2.regenerate')}</ActionButton>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@ -157,9 +155,7 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<form action="?/removeUrl&id={url.id}" method="POST">
|
||||
<Button type="submit" variant="link">{$t('common.remove')}</Button>
|
||||
</form>
|
||||
<ActionButton action="?/removeUrl&id={url.id}">{$t('common.remove')}</ActionButton>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@ -208,9 +204,10 @@
|
||||
<span class="privilege-name"
|
||||
><span class="privilege-id">{idPart}:</span>{rest.join(':')}</span
|
||||
>
|
||||
<form action="?/removePrivilege&id={privilege.id}" method="POST">
|
||||
<Button type="submit" variant="link">{$t('common.remove')}</Button>
|
||||
</form>
|
||||
|
||||
<ActionButton action="?/removePrivilege&id={privilege.id}"
|
||||
>{$t('common.remove')}</ActionButton
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@ -249,7 +246,7 @@
|
||||
<p>{$t('admin.oauth2.grantsHint')}</p>
|
||||
|
||||
<form action="?/grants" method="POST">
|
||||
<div class="scope-cloud">
|
||||
<div class="checkbox-grid">
|
||||
{#each data.availableGrants as grant}
|
||||
<FormControl>
|
||||
<input
|
||||
@ -272,7 +269,7 @@
|
||||
<p>{$t('admin.oauth2.scopesHint')}</p>
|
||||
|
||||
<form action="?/scopes" method="POST">
|
||||
<div class="scope-cloud">
|
||||
<div class="checkbox-grid">
|
||||
{#each data.availableScopes as scope}
|
||||
<FormControl>
|
||||
<input
|
||||
@ -301,9 +298,10 @@
|
||||
{#each data.managers as user}
|
||||
<div class="addremove-item">
|
||||
<b>{user.email}</b>
|
||||
<form action="?/removeManager&id={user.id}" method="POST">
|
||||
<Button type="submit" variant="link">{$t('common.remove')}</Button>
|
||||
</form>
|
||||
|
||||
<ActionButton action="?/removeManager&id={user.id}"
|
||||
>{$t('common.remove')}</ActionButton
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@ -392,9 +390,11 @@
|
||||
<div class="auth-user-name">
|
||||
<b>{user.uuid}</b> ({user.name})
|
||||
</div>
|
||||
|
||||
<div class="auth-user-privileges">
|
||||
{user.privileges.map(({ name }) => name).join(', ')}
|
||||
</div>
|
||||
|
||||
{#if !user.current}
|
||||
<b>{$t('admin.oauth2.revoked')}</b>
|
||||
{/if}
|
||||
@ -425,13 +425,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: #fff;
|
||||
background-color: var(--ina-card-background);
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
& .privilege-id {
|
||||
color: #646464;
|
||||
color: var(--ina-card-text-muted);
|
||||
}
|
||||
|
||||
& .url-type {
|
||||
@ -444,10 +444,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.scope-cloud {
|
||||
.checkbox-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -3,7 +3,7 @@ import { Changesets } from '$lib/server/changesets.js';
|
||||
import type { OAuth2Client, User } from '$lib/server/drizzle';
|
||||
import { OAuth2Clients } from '$lib/server/oauth2';
|
||||
import { Users } from '$lib/server/users';
|
||||
import { UsersAdmin } from '$lib/server/users/admin';
|
||||
import { hasPrivileges } from '$lib/utils';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
|
||||
interface PrivilegesRequest {
|
||||
@ -12,15 +12,17 @@ interface PrivilegesRequest {
|
||||
|
||||
export const actions = {
|
||||
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']
|
||||
]);
|
||||
|
||||
const fullPrivileges = hasPrivileges(userSession.privileges || [], ['admin:oauth2']);
|
||||
|
||||
const {
|
||||
list: [details]
|
||||
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
|
||||
clientId: uuid,
|
||||
listAll: false,
|
||||
listAll: fullPrivileges,
|
||||
omitSecret: false
|
||||
});
|
||||
|
||||
@ -67,11 +69,13 @@ export const load = async ({ params: { uuid, user: userId }, parent }) => {
|
||||
const currentUser = await Users.getBySession(user);
|
||||
AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2']]);
|
||||
|
||||
const fullPrivileges = hasPrivileges(user.privileges || [], ['admin:oauth2']);
|
||||
|
||||
const {
|
||||
list: [details]
|
||||
} = await OAuth2Clients.getClientByAdminUser(currentUser as User, {
|
||||
clientId: uuid,
|
||||
listAll: false,
|
||||
listAll: fullPrivileges,
|
||||
omitSecret: false
|
||||
});
|
||||
|
||||
|
@ -34,6 +34,5 @@ export const GET = async ({ locals, url }) => {
|
||||
await OAuth2Clients.addManager(client, inviter, currentUser);
|
||||
await UserTokens.remove(fetch);
|
||||
|
||||
console.log('?');
|
||||
return ApiUtils.redirect(`/ssoadmin/oauth2/${client.client_id}`);
|
||||
};
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { AdminUtils } from '$lib/server/admin-utils';
|
||||
import { Changesets } from '$lib/server/changesets.js';
|
||||
import { OAuth2Clients } from '$lib/server/oauth2/index.js';
|
||||
import { UsersAdmin } from '$lib/server/users/admin';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
|
||||
interface CreateClientRequest {
|
||||
@ -12,8 +11,8 @@ interface CreateClientRequest {
|
||||
|
||||
export const actions = {
|
||||
default: async ({ locals, request }) => {
|
||||
const { currentUser } = await UsersAdmin.getActionUser(locals, [
|
||||
['admin:oauth2', 'self:oauth2']
|
||||
const { currentUser } = await AdminUtils.getActionUser(locals, [
|
||||
['admin:oauth2', 'self:oauth2:create']
|
||||
]);
|
||||
|
||||
const body = await request.formData();
|
||||
@ -42,7 +41,7 @@ export const actions = {
|
||||
|
||||
export const load = async ({ parent }) => {
|
||||
const { user } = await parent();
|
||||
AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2']]);
|
||||
AdminUtils.checkPrivileges(user, [['admin:oauth2', 'self:oauth2:create']]);
|
||||
|
||||
return {};
|
||||
};
|
||||
|
@ -19,7 +19,7 @@ interface UpdateRequest {
|
||||
export const actions = {
|
||||
removeOtp: async () => {},
|
||||
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);
|
||||
if (!targetUser) {
|
||||
@ -31,7 +31,7 @@ export const actions = {
|
||||
return { errors: [] };
|
||||
},
|
||||
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);
|
||||
if (!targetUser || !!targetUser.activated) {
|
||||
@ -57,7 +57,7 @@ export const actions = {
|
||||
return { errors: [] };
|
||||
},
|
||||
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';
|
||||
if (!type) {
|
||||
@ -85,7 +85,7 @@ export const actions = {
|
||||
return { errors: [] };
|
||||
},
|
||||
update: async ({ locals, params: { uuid }, request }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
|
||||
const { currentUser, userSession } = await AdminUtils.getActionUser(locals, [
|
||||
'admin',
|
||||
'admin:user'
|
||||
]);
|
||||
|
@ -11,6 +11,8 @@
|
||||
import AdminPrivilegesSelect from '$lib/components/admin/AdminPrivilegesSelect.svelte';
|
||||
import FormErrors from '$lib/components/form/FormErrors.svelte';
|
||||
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 form: ActionData;
|
||||
@ -30,13 +32,14 @@
|
||||
<AvatarCard src={`/api/avatar/${data.details.uuid}?t=${data.renderrt}`}>
|
||||
<ColumnView>
|
||||
{#if data.details.pictureId}
|
||||
<form action="?/removeAvatar" method="POST">
|
||||
<Button type="submit" variant="link">{$t('account.avatar.remove')}</Button>
|
||||
</form>
|
||||
<ActionButton action="?/removeAvatar">{$t('account.avatar.remove')}</ActionButton>
|
||||
{/if}
|
||||
</ColumnView>
|
||||
</AvatarCard>
|
||||
|
||||
{#if form?.errors && !form.errors.length}
|
||||
<Alert type="success">{$t('admin.oauth2.success')}</Alert>
|
||||
{/if}
|
||||
<FormErrors errors={form?.errors || []} prefix="admin.users.errors" />
|
||||
|
||||
<form action="?/update" method="POST">
|
||||
@ -81,20 +84,15 @@
|
||||
<h3>{$t('account.otp.title')}</h3>
|
||||
<p>{$t(`account.otp.${data.details.otpEnabled ? 'enabled' : 'unavailable'}`)}</p>
|
||||
{#if data.details.otpEnabled}
|
||||
<form action="?/removeOtp" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.users.deactivateOtp')}</Button>
|
||||
</form>
|
||||
<ActionButton action="?/removeOtp">{$t('admin.users.deactivateOtp')}</ActionButton>
|
||||
{/if}
|
||||
|
||||
<h3>{$t('admin.users.actions')}</h3>
|
||||
{#if data.details.activated}
|
||||
<form action="?/email&type=password" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.users.passwordEmail')}</Button>
|
||||
</form>
|
||||
<ActionButton action="?/email&type=password">{$t('admin.users.passwordEmail')}</ActionButton>
|
||||
{:else}
|
||||
<form action="?/email&type=activate" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.users.activationEmail')}</Button>
|
||||
</form>
|
||||
<ActionButton action="?/email&type=activate">{$t('admin.users.activationEmail')}</ActionButton
|
||||
>
|
||||
<form action="?/deleteInfo" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.users.deleteInfo')}</Button>
|
||||
- <span>{$t('admin.users.deleteInfoHint')}</span>
|
||||
|
Loading…
Reference in New Issue
Block a user