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

@ -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!"
}
}
}

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

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>