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,
*::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
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 { 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
});

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);
width: 100%;
border-radius: 8px;
padding: 24px 28px;
padding: 16px 18px;
font-size: 1.15rem;
& p {

View File

@ -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>

View File

@ -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>

View File

@ -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);
}

View File

@ -16,7 +16,7 @@
href={`?page=${pageNum - 1}`}
tabindex={firstPage ? -1 : 0}
aria-label={$t('common.previous')}
aria-disabled={firstPage}>&lt;&lt;</a
aria-disabled={firstPage}>&lt;</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}>&gt;&gt;</a
aria-disabled={lastPage}>&gt;</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>

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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;
}

View File

@ -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%;

View File

@ -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) {

View File

@ -1,130 +1,132 @@
{
"title": "Admin",
"menu": {
"users": "Users",
"oauth2": "OAuth2 applications",
"audit": "Audit logs"
},
"users": {
"title": "Users",
"uuid": "UUID",
"email": "Email",
"privileges": "Privileges",
"actions": "Account actions",
"activated": "Activated",
"registered": "Registered",
"deactivate": "Deactivate account",
"deactivateOtp": "Remove two-factor authentication",
"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.",
"passwordEmail": "Send password email",
"activationEmail": "Send activation email",
"errors": {
"invalidUuid": "Invalid user or impossible action",
"invalidEmailType": "Invalid email type",
"unauthorized": "Unauthorized changes",
"lockout": "You cannot lock yourself out!",
"invalidDisplayName": "Invalid display name",
"invalidEmail": "Invalid email address"
}
},
"oauth2": {
"title": "OAuth2 applications",
"new": "Create a new application",
"clientTitle": "Application name",
"clientId": "Client ID",
"clientSecret": "Client secret",
"description": "Application description",
"reveal": "Reveal secret",
"regenerate": "Regenerate secret",
"activated": "Activated",
"verified": "Official",
"scopes": "Available scopes",
"scopesHint": "The level of access to information you will be needing for this application.",
"grants": "Available grant types",
"grantsHint": "The OAuth2 authorization flows you will be using with this application.",
"created": "Created at",
"owner": "Created by",
"ownerMe": "that's you!",
"actions": "Actions",
"delete": "Delete application permanently",
"avatar": {
"title": "Application icon",
"remove": "Delete application icon",
"change": "Change application icon"
},
"authorizations": "Authorized users",
"authorizationsHint": "These users have authorized this application at least once. You may assign application privileges to each user individually.",
"revoked": "This authorization has been revoked by the user",
"noAuthorizations": "There are no authorizations on record for this application.",
"privileges": {
"title": "Application privileges",
"name": "Privilege name",
"nameHint": "An unique prefix \"{{prefix}}\" will be prepended automatically. English alphabet and ._-: characters only.",
"add": "Add a new privilege",
"addHint": "You may assign application-specific privileges to your authorized users. You may use this system for permissions or for tagging your users, all up to you!",
"remove": "Remove",
"manage": "Manage user privileges",
"new": "Add a new privilege",
"edit": "Edit privileges"
},
"urls": {
"title": "Application URLs",
"new": "Add a new URL",
"type": "URL type",
"url": "URL",
"types": {
"website": "Website",
"privacy": "Privacy Policy",
"terms": "Terms of Service",
"redirect_uri": "Redirect URI"
},
"add": "Add URL"
},
"apis": {
"title": "OAuth2 APIs",
"authorize": "OAuth2 Authorization endpoint",
"token": "OAuth2 Token endpoint",
"introspect": "OAuth2 Introspection endpoint",
"userinfo": "User information endpoint (Bearer)",
"openid": "OpenID Connect configuration"
},
"grantTexts": {
"authorization_code": "Authorization code",
"client_credentials": "Client credentials",
"refresh_token": "Refresh token",
"implicit": "Implicit token",
"id_token": "ID token (OpenID Connect)"
},
"scopeTexts": {
"picture": "Access profile picture URL",
"profile": "Basic profile information",
"email": "Access user email address",
"privileges": "Access user privilege list",
"management": "Manage your application",
"account": "Change user account settings",
"openid": "Get an ID token JWT (OpenID Connect)"
},
"managers": {
"title": "Application members",
"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.",
"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!",
"forbidden": "This action is forbidden for this user.",
"invalidTitle": "Name must be between 3 and 32 characters long.",
"invalidDescription": "Description must be at most 1000 characters long.",
"deleteActivated": "Cannot delete an active application. Please deactivate it first.",
"invalidUrlId": "Invalid URL ID for deletion.",
"invalidUrlType": "Invalid URL type provided.",
"invalidUrl": "Invalid URL provided.",
"invalidPrivilegeId": "Invalid privilege ID for deletion.",
"invalidPrivilege": "Invalid privilege provided.",
"invalidEmail": "Invalid email address.",
"emailExists": "This email address is already added.",
"noFile": "Please upload a file first."
}
}
"title": "Admin",
"menu": {
"users": "Users",
"oauth2": "OAuth2 applications",
"audit": "Audit logs"
},
"users": {
"title": "Users",
"uuid": "UUID",
"email": "Email",
"privileges": "Privileges",
"actions": "Account actions",
"activated": "Activated",
"registered": "Registered",
"deactivate": "Deactivate account",
"deactivateOtp": "Remove two-factor authentication",
"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.",
"passwordEmail": "Send password email",
"activationEmail": "Send activation email",
"errors": {
"invalidUuid": "Invalid user or impossible action",
"invalidEmailType": "Invalid email type",
"unauthorized": "Unauthorized changes",
"lockout": "You cannot lock yourself out!",
"invalidDisplayName": "Invalid display name",
"invalidEmail": "Invalid email address"
}
},
"oauth2": {
"title": "OAuth2 applications",
"new": "Create a new application",
"clientTitle": "Application name",
"clientId": "Client ID",
"clientSecret": "Client secret",
"description": "Application description",
"reveal": "Reveal secret",
"regenerate": "Regenerate secret",
"activated": "Activated",
"verified": "Official",
"scopes": "Available scopes",
"scopesHint": "The level of access to information you will be needing for this application.",
"grants": "Available grant types",
"grantsHint": "The OAuth2 authorization flows you will be using with this application.",
"created": "Created at",
"owner": "Created by",
"ownerMe": "that's you!",
"actions": "Actions",
"delete": "Delete application permanently",
"avatar": {
"title": "Application icon",
"remove": "Delete application icon",
"change": "Change application icon"
},
"authorizations": "Authorized users",
"authorizationsHint": "These users have authorized this application at least once. You may assign application privileges to each user individually.",
"revoked": "This authorization has been revoked by the user",
"noAuthorizations": "There are no authorizations on record for this application.",
"success": "Success!",
"privileges": {
"title": "Application privileges",
"name": "Privilege name",
"nameHint": "An unique prefix \"{{prefix}}\" will be prepended automatically. English alphabet and ._-: characters only.",
"add": "Add a new privilege",
"addHint": "You may assign application-specific privileges to your authorized users. You may use this system for permissions or for tagging your users, all up to you!",
"remove": "Remove",
"manage": "Manage user privileges",
"new": "Add a new privilege",
"edit": "Edit privileges"
},
"urls": {
"title": "Application URLs",
"new": "Add a new URL",
"type": "URL type",
"url": "URL",
"types": {
"website": "Website",
"privacy": "Privacy Policy",
"terms": "Terms of Service",
"redirect_uri": "Redirect URI"
},
"add": "Add URL"
},
"apis": {
"title": "OAuth2 APIs",
"authorize": "OAuth2 Authorization endpoint",
"token": "OAuth2 Token endpoint",
"introspect": "OAuth2 Introspection endpoint",
"userinfo": "User information endpoint (Bearer)",
"openid": "OpenID Connect configuration"
},
"grantTexts": {
"authorization_code": "Authorization code",
"client_credentials": "Client credentials",
"refresh_token": "Refresh token",
"implicit": "Implicit token",
"id_token": "ID token (OpenID Connect)"
},
"scopeTexts": {
"picture": "Access profile picture URL",
"profile": "Basic profile information",
"email": "Access user email address",
"privileges": "Access user privilege list",
"management": "Manage your application",
"account": "Change user account settings",
"openid": "Get an ID token JWT (OpenID Connect)"
},
"managers": {
"title": "Application members",
"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.",
"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!",
"forbidden": "This action is forbidden for this user.",
"invalidTitle": "Name must be between 3 and 32 characters long.",
"invalidDescription": "Description must be at most 1000 characters long.",
"deleteActivated": "Cannot delete an active application. Please deactivate it first.",
"invalidUrlId": "Invalid URL ID for deletion.",
"invalidUrlType": "Invalid URL type provided.",
"invalidUrl": "Invalid URL provided.",
"invalidPrivilegeId": "Invalid privilege ID for deletion.",
"invalidPrivilege": "Invalid privilege provided.",
"invalidEmail": "Invalid email address.",
"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.",
"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",
"cancel": "Cancel",
"manage": "Manage",

View File

@ -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 };
}
}

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}.
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>

View File

@ -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,

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 {
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 };
}
}

View File

@ -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)}`;

View File

@ -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>
<h1>{PUBLIC_SITE_NAME}</h1>
<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>

View File

@ -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}
<Button type="submit" variant="primary" disabled={submitted}
>{$t('account.submit')}</Button
>
<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}

View File

@ -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>
<Button type="submit" variant="primary" disabled={submitted}
>{$t('account.register.submit')}</Button
>
<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>

View File

@ -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;
}
}

View File

@ -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
};
};

View File

@ -18,7 +18,9 @@
<TitleRow>
<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>
<ColumnView>

View File

@ -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,25 +30,49 @@ interface InviteRequest {
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 = {
/**
* Update the OAuth2 Client general information.
*/
update: 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 body = await request.formData();
const { title, description, activated, verified } = Changesets.take<UpdateRequest>(
@ -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
};

View File

@ -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>

View File

@ -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
});

View File

@ -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}`);
};

View File

@ -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 {};
};

View File

@ -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'
]);

View File

@ -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>