user management mostly done
This commit is contained in:
parent
1f5de32f61
commit
d258880ac4
5
src/app.d.ts
vendored
5
src/app.d.ts
vendored
@ -9,6 +9,11 @@ type SessionData = {
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
type PartialK<T, K extends PropertyKey = PropertyKey> = Partial<Pick<T, Extract<keyof T, K>>> &
|
||||
Omit<T, K> extends infer O
|
||||
? { [P in keyof O]: O[P] }
|
||||
: never;
|
||||
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
|
||||
|
@ -29,6 +29,7 @@
|
||||
padding: 4px 8px 4px 4px;
|
||||
background-color: #004edf;
|
||||
border-radius: 40px;
|
||||
color: #fff;
|
||||
|
||||
& .admin-user-avatar {
|
||||
width: 32px;
|
||||
@ -40,5 +41,10 @@
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
|
||||
&:visited {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
99
src/lib/components/admin/AdminPrivilegesSelect.svelte
Normal file
99
src/lib/components/admin/AdminPrivilegesSelect.svelte
Normal file
@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import Button from '../Button.svelte';
|
||||
import ColumnView from '../container/ColumnView.svelte';
|
||||
import FormControl from '../form/FormControl.svelte';
|
||||
import FormSection from '../form/FormSection.svelte';
|
||||
|
||||
interface PrivilegeType {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export let available: PrivilegeType[];
|
||||
export let current: PrivilegeType[];
|
||||
|
||||
let currentState = [...current];
|
||||
$: availableState = available.filter(({ id }) => !currentState.some((entry) => entry.id === id));
|
||||
$: selectedIds = currentState.map(({ id }) => id);
|
||||
|
||||
let availableSelect: HTMLSelectElement;
|
||||
let currentSelect: HTMLSelectElement;
|
||||
|
||||
const transferToCurrent = () => {
|
||||
currentState = [
|
||||
...Array.from(availableSelect.selectedOptions).map(
|
||||
(opt) =>
|
||||
available.find(({ id }) => id === Number(opt.getAttribute('value'))) as PrivilegeType
|
||||
),
|
||||
...currentState
|
||||
];
|
||||
};
|
||||
|
||||
const transferToAvailable = () => {
|
||||
const opts = Array.from(currentSelect.selectedOptions);
|
||||
currentState = [
|
||||
...currentState.filter(
|
||||
({ id }) => !opts.some((opt) => id === Number(opt.getAttribute('value')))
|
||||
)
|
||||
];
|
||||
};
|
||||
</script>
|
||||
|
||||
<FormSection title={$t('admin.users.privileges')}>
|
||||
<input type="hidden" value={selectedIds} name="privileges" />
|
||||
|
||||
<div class="transfer-box">
|
||||
<ColumnView>
|
||||
<FormControl>
|
||||
<label for="priv-available">{$t('common.available')}</label>
|
||||
<select id="priv-available" multiple bind:this={availableSelect}>
|
||||
{#each availableState as item}
|
||||
<option value={item.id}>{item.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</FormControl>
|
||||
</ColumnView>
|
||||
|
||||
<ColumnView>
|
||||
<Button variant="primary" on:click={transferToAvailable}><<</Button>
|
||||
<Button variant="primary" on:click={transferToCurrent}>>></Button>
|
||||
</ColumnView>
|
||||
|
||||
<ColumnView>
|
||||
<FormControl>
|
||||
<label for="priv-current">{$t('common.current')}</label>
|
||||
<select id="priv-current" multiple bind:this={currentSelect}>
|
||||
{#each currentState as item}
|
||||
<option value={item.id}>{item.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</FormControl>
|
||||
</ColumnView>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<style>
|
||||
.transfer-box {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.transfer-box > :global(.column):first-child,
|
||||
.transfer-box > :global(.column):last-child {
|
||||
flex-basis: 45%;
|
||||
}
|
||||
|
||||
.transfer-box > :global(.column):nth-child(2) {
|
||||
margin-top: 1.35rem;
|
||||
}
|
||||
|
||||
.transfer-box :global(.form-control) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
select {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
@ -67,6 +67,14 @@
|
||||
display: block;
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
|
||||
&.active {
|
||||
background-color: #c7c7c7;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #e4e4e4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
81
src/lib/components/admin/AdminUserCard.svelte
Normal file
81
src/lib/components/admin/AdminUserCard.svelte
Normal file
@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import type { PageData } from '../../../routes/ssoadmin/users/$types';
|
||||
|
||||
export let user: PageData['list'][0];
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeStyle: 'medium' });
|
||||
const formatDate = dateFormat.format.bind(null);
|
||||
</script>
|
||||
|
||||
<div class="user">
|
||||
<div class="user-avatar-wrapper">
|
||||
<img class="user-avatar" src={`/api/avatar/${user.uuid}`} alt={user.display_name} />
|
||||
</div>
|
||||
|
||||
<div class="user-info">
|
||||
<h2 class="user-name">{user.display_name}</h2>
|
||||
<span class="user-username">@{user.username}</span>
|
||||
|
||||
<dl>
|
||||
<dt>{$t('admin.users.uuid')}</dt>
|
||||
<dd>{user.uuid}</dd>
|
||||
|
||||
<dt>{$t('admin.users.email')}</dt>
|
||||
<dd>{user.email}</dd>
|
||||
|
||||
{#if user.privileges.length}
|
||||
<dt>{$t('admin.users.privileges')}</dt>
|
||||
<dd>{user.privileges.map(({ name }) => name).join(', ')}</dd>
|
||||
{/if}
|
||||
|
||||
<dt>{$t('admin.users.activated')}</dt>
|
||||
<dd>{$t(`common.bool.${Boolean(user.activated)}`)}</dd>
|
||||
|
||||
<dt>{$t('admin.users.registered')}</dt>
|
||||
<dd>{formatDate(user.created_at)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.user {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 8px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
flex-grow: 1;
|
||||
|
||||
transition: transform 100ms linear;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
& .user-avatar {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
& .user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > dl {
|
||||
margin: 0;
|
||||
|
||||
& > dt {
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .user-name {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -55,4 +55,14 @@
|
||||
margin-left: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.form-control:has(input[type='checkbox']) {
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-control:has(input[type='checkbox']) > :global(label) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
@ -71,6 +71,7 @@
|
||||
"title": "Two-factor authentication",
|
||||
"enabled": "Two-factor authentication is enabled.",
|
||||
"disabled": "Your account does not have two-factor authentication enabled.",
|
||||
"unavailable": "Two-factor authentication is not set up.",
|
||||
"activated": "Two-factor authentication has been activated successfully!",
|
||||
"deactivated": "Two-factor authentication has been deactivated successfully.",
|
||||
"scan": "Scan this QR code with the authenticator app of your choice",
|
||||
|
@ -10,7 +10,22 @@
|
||||
"uuid": "UUID",
|
||||
"email": "Email",
|
||||
"privileges": "Privileges",
|
||||
"actions": "Account actions",
|
||||
"activated": "Activated",
|
||||
"registered": "Registered"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,5 +13,7 @@
|
||||
"bool": {
|
||||
"true": "Yes",
|
||||
"false": "No"
|
||||
}
|
||||
},
|
||||
"available": "Available",
|
||||
"current": "Current"
|
||||
}
|
||||
|
@ -99,6 +99,10 @@ export class OAuth2Tokens {
|
||||
);
|
||||
}
|
||||
|
||||
static async wipeUserTokens(user: User) {
|
||||
await db.delete(oauth2Token).where(eq(oauth2Token.userId, user.id));
|
||||
}
|
||||
|
||||
static async wipeExpiredTokens() {
|
||||
await db.execute(sql`DELETE FROM ${oauth2Token} WHERE ${oauth2Token.expires_at} < NOW()`);
|
||||
}
|
||||
|
@ -8,12 +8,43 @@ 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 {
|
||||
static mergeUserResponse(
|
||||
junkList: {
|
||||
user: User;
|
||||
user_privileges_privilege?: typeof userPrivilegesPrivilege.$inferSelect | null;
|
||||
privilege?: Privilege | null;
|
||||
}[]
|
||||
) {
|
||||
return junkList.reduce<AdminUserListItem[]>((accum, dbe) => {
|
||||
let user = accum.find((entry) => entry.id === dbe.user.id);
|
||||
if (!user) {
|
||||
user = { ...dbe.user, password: undefined, privileges: [] } as AdminUserListItem;
|
||||
accum.push(user);
|
||||
}
|
||||
|
||||
// Individual privilege
|
||||
if (dbe.user_privileges_privilege && dbe.privilege) {
|
||||
if (
|
||||
!user.privileges.some((priv) => priv.id === dbe.user_privileges_privilege?.privilegeId)
|
||||
) {
|
||||
user.privileges.push(dbe.privilege);
|
||||
}
|
||||
}
|
||||
|
||||
return accum;
|
||||
}, []);
|
||||
}
|
||||
|
||||
static async getAllUsers({
|
||||
filter,
|
||||
offset = 0,
|
||||
@ -53,27 +84,35 @@ export class UsersAdmin {
|
||||
pageCount: Math.ceil(rowCount / limit)
|
||||
};
|
||||
|
||||
const list = junkList.reduce<AdminUserListItem[]>((accum, dbe) => {
|
||||
let user = accum.find((entry) => entry.id === dbe.user.id);
|
||||
if (!user) {
|
||||
user = { ...dbe.user, password: undefined, privileges: [] } as AdminUserListItem;
|
||||
accum.push(user);
|
||||
}
|
||||
|
||||
if (dbe.user_privileges_privilege && dbe.privilege) {
|
||||
if (
|
||||
!user.privileges.some((priv) => priv.id === dbe.user_privileges_privilege?.privilegeId)
|
||||
) {
|
||||
user.privileges.push(dbe.privilege);
|
||||
}
|
||||
}
|
||||
|
||||
return accum;
|
||||
}, []);
|
||||
const list = UsersAdmin.mergeUserResponse(junkList);
|
||||
|
||||
return <Paginated<AdminUserListItem>>{
|
||||
list,
|
||||
meta
|
||||
};
|
||||
}
|
||||
|
||||
static async getUserDetails(uuid: string) {
|
||||
const junkList = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.leftJoin(userPrivilegesPrivilege, eq(userPrivilegesPrivilege.userId, user.id))
|
||||
.leftJoin(privilege, eq(userPrivilegesPrivilege.privilegeId, privilege.id))
|
||||
.where(eq(user.uuid, uuid));
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { and, eq, or, sql } from 'drizzle-orm';
|
||||
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
import { db, privilege, user, userPrivilegesPrivilege, type User } from '../drizzle';
|
||||
import type { UserSession } from './types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
@ -19,11 +19,11 @@ export class Users {
|
||||
return result;
|
||||
}
|
||||
|
||||
static async getByUuid(uuid: string): Promise<User | undefined> {
|
||||
static async getByUuid(uuid: string, activatedCheck = true): Promise<User | undefined> {
|
||||
const [result] = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(and(eq(user.uuid, uuid), eq(user.activated, 1)))
|
||||
.where(and(eq(user.uuid, uuid), activatedCheck ? eq(user.activated, 1) : undefined))
|
||||
.limit(1);
|
||||
return result;
|
||||
}
|
||||
@ -214,6 +214,13 @@ export class Users {
|
||||
}
|
||||
}
|
||||
|
||||
static async getAvailablePrivileges(clientId?: number) {
|
||||
return await db
|
||||
.select()
|
||||
.from(privilege)
|
||||
.where(clientId ? eq(privilege.clientId, clientId) : isNull(privilege.clientId));
|
||||
}
|
||||
|
||||
static async getUserPrivileges(subject: User) {
|
||||
const list = await db
|
||||
.select({
|
||||
@ -229,6 +236,43 @@ export class Users {
|
||||
);
|
||||
}
|
||||
|
||||
static async setUserPrivileges(subject: User, privilegeIds: number[]) {
|
||||
const current = await db
|
||||
.select({
|
||||
privilegeId: userPrivilegesPrivilege.privilegeId
|
||||
})
|
||||
.from(userPrivilegesPrivilege)
|
||||
.where(eq(userPrivilegesPrivilege.userId, subject.id));
|
||||
|
||||
const toRemoveIds = current.reduce<number[]>(
|
||||
(list, { privilegeId }) =>
|
||||
!privilegeIds.includes(privilegeId) ? [...list, privilegeId] : list,
|
||||
[]
|
||||
);
|
||||
|
||||
if (toRemoveIds.length) {
|
||||
await db
|
||||
.delete(userPrivilegesPrivilege)
|
||||
.where(
|
||||
and(
|
||||
eq(userPrivilegesPrivilege.userId, subject.id),
|
||||
inArray(userPrivilegesPrivilege.privilegeId, toRemoveIds)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const toInsertIds = privilegeIds.reduce<number[]>(
|
||||
(list, id) => (!current.some(({ privilegeId }) => privilegeId === id) ? [...list, id] : list),
|
||||
[]
|
||||
);
|
||||
|
||||
if (toInsertIds.length) {
|
||||
await db
|
||||
.insert(userPrivilegesPrivilege)
|
||||
.values(toInsertIds.map((privilegeId) => ({ userId: subject.id, privilegeId })));
|
||||
}
|
||||
}
|
||||
|
||||
static anonymizeEmail(email: string) {
|
||||
const [name, domain] = email.split('@');
|
||||
const namePart = `${name.charAt(0)}${''.padStart(name.length - 2, '*')}${name.charAt(name.length - 1)}`;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { and, eq, gt, isNull, or, sql } from 'drizzle-orm';
|
||||
import { CryptoUtils } from '../crypto-utils';
|
||||
import { db, userToken, type UserToken } from '../drizzle';
|
||||
import { db, userToken, type User, type UserToken } from '../drizzle';
|
||||
|
||||
export class UserTokens {
|
||||
static async create(
|
||||
@ -41,6 +41,10 @@ export class UserTokens {
|
||||
return returned;
|
||||
}
|
||||
|
||||
static async wipeUserTokens(user: User) {
|
||||
await db.delete(userToken).where(eq(userToken.userId, user.id));
|
||||
}
|
||||
|
||||
static async wipeExpiredTokens() {
|
||||
await db.execute(sql`DELETE FROM ${userToken} WHERE ${userToken.expires_at} < NOW()`);
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ export class TimeOTP {
|
||||
return totp.generateSecret();
|
||||
}
|
||||
|
||||
public static async isUserOtp(subject: User) {
|
||||
public static async isUserOtp(subject: PartialK<User, 'password'>) {
|
||||
const tokens = await db
|
||||
.select({ id: userToken.id })
|
||||
.from(userToken)
|
||||
@ -31,7 +31,7 @@ export class TimeOTP {
|
||||
or(isNull(userToken.expires_at), gt(userToken.expires_at, new Date()))
|
||||
)
|
||||
);
|
||||
return tokens?.length;
|
||||
return !!tokens?.length;
|
||||
}
|
||||
|
||||
public static async getUserOtp(subject: User) {
|
||||
|
@ -16,6 +16,7 @@ export const load = async ({ url, locals }) => {
|
||||
}
|
||||
|
||||
return {
|
||||
renderrt: Date.now(),
|
||||
user: {
|
||||
...userInfo,
|
||||
privileges
|
||||
|
@ -18,6 +18,12 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-wrapper {
|
||||
--in-text-color: #000;
|
||||
--in-link-color: #000;
|
||||
--in-error-color: #ff8080;
|
||||
}
|
||||
|
||||
.admin-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -2,11 +2,9 @@
|
||||
import Paginator from '$lib/components/Paginator.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { PageData } from './$types';
|
||||
import UserCard from '$lib/components/admin/AdminUserCard.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'short', timeStyle: 'medium' });
|
||||
const formatDate = dateFormat.format.bind(null);
|
||||
</script>
|
||||
|
||||
<h1>{$t('admin.users.title')}</h1>
|
||||
@ -14,89 +12,22 @@
|
||||
<div class="user-list">
|
||||
<Paginator meta={data.meta} />
|
||||
{#each data.list as user}
|
||||
<a href={`users/${user.uuid}`} class="user-wrapper">
|
||||
<div class="user">
|
||||
<div class="user-avatar-wrapper">
|
||||
<img class="user-avatar" src={`/api/avatar/${user.uuid}`} alt={user.display_name} />
|
||||
</div>
|
||||
|
||||
<div class="user-info">
|
||||
<h2 class="user-name">{user.display_name}</h2>
|
||||
<span class="user-username">@{user.username}</span>
|
||||
|
||||
<dl>
|
||||
<dt>{$t('admin.users.uuid')}</dt>
|
||||
<dd>{user.uuid}</dd>
|
||||
|
||||
<dt>{$t('admin.users.email')}</dt>
|
||||
<dd>{user.email}</dd>
|
||||
|
||||
{#if user.privileges.length}
|
||||
<dt>{$t('admin.users.privileges')}</dt>
|
||||
<dd>{user.privileges.map(({ name }) => name).join(', ')}</dd>
|
||||
{/if}
|
||||
|
||||
<dt>{$t('admin.users.activated')}</dt>
|
||||
<dd>{$t(`common.bool.${Boolean(user.activated)}`)}</dd>
|
||||
|
||||
<dt>{$t('admin.users.registered')}</dt>
|
||||
<dd>{formatDate(user.created_at)}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<a href={`users/${user.uuid}`} class="user-link">
|
||||
<UserCard {user} />
|
||||
</a>
|
||||
{/each}
|
||||
<Paginator meta={data.meta} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.user-link {
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-wrapper {
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
transition: transform 100ms linear;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.01);
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 8px;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
flex-grow: 1;
|
||||
|
||||
& .user-avatar {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
& .user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > dl {
|
||||
margin: 0;
|
||||
& > dt {
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
& .user-name {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
161
src/routes/ssoadmin/users/[slug]/+page.server.ts
Normal file
161
src/routes/ssoadmin/users/[slug]/+page.server.ts
Normal file
@ -0,0 +1,161 @@
|
||||
import { AdminUtils } from '$lib/server/admin-utils';
|
||||
import { Changesets } from '$lib/server/changesets.js';
|
||||
import { OAuth2Tokens } from '$lib/server/oauth2/index.js';
|
||||
import { Uploads } from '$lib/server/upload.js';
|
||||
import { UsersAdmin } from '$lib/server/users/admin.js';
|
||||
import { UserTokens, Users } from '$lib/server/users/index.js';
|
||||
import { TimeOTP } from '$lib/server/users/totp.js';
|
||||
import { hasPrivileges } from '$lib/utils.js';
|
||||
import { emailRegex } from '$lib/validators.js';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
|
||||
interface UpdateRequest {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
activated?: boolean;
|
||||
privileges?: string;
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
removeOtp: async () => {},
|
||||
removeAvatar: async ({ locals, params: { slug: uuid } }) => {
|
||||
await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']);
|
||||
|
||||
const targetUser = await Users.getByUuid(uuid, false);
|
||||
if (!targetUser) {
|
||||
return fail(404, { errors: ['invalidUuid'] });
|
||||
}
|
||||
|
||||
await Uploads.removeAvatar(targetUser);
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
deleteInfo: async ({ locals, params: { slug: uuid } }) => {
|
||||
await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']);
|
||||
|
||||
const targetUser = await Users.getByUuid(uuid, false);
|
||||
if (!targetUser || !!targetUser.activated) {
|
||||
return fail(404, { errors: ['invalidUuid'] });
|
||||
}
|
||||
|
||||
// TODO: audit log
|
||||
|
||||
const [stubName] = uuid.split('-');
|
||||
|
||||
// Nuke EVERYTHING
|
||||
await UserTokens.wipeUserTokens(targetUser);
|
||||
await OAuth2Tokens.wipeUserTokens(targetUser);
|
||||
await Uploads.removeAvatar(targetUser);
|
||||
await Users.update(targetUser, {
|
||||
username: `stub${stubName}`,
|
||||
display_name: `Stub ${stubName}`,
|
||||
email: `${stubName}@uuid-stub.target`,
|
||||
activated: 0,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
email: async ({ locals, params: { slug: uuid }, url }) => {
|
||||
await UsersAdmin.getActionUser(locals, ['admin', 'admin:user']);
|
||||
|
||||
const type = url.searchParams.get('type') as 'password' | 'activate';
|
||||
if (!type) {
|
||||
return fail(403, { errors: ['invalidEmailType'] });
|
||||
}
|
||||
|
||||
const targetUser = await Users.getByUuid(uuid, false);
|
||||
if (!targetUser) {
|
||||
return fail(404, { errors: ['invalidUuid'] });
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'password':
|
||||
await Users.sendPasswordEmail(targetUser);
|
||||
break;
|
||||
case 'activate':
|
||||
await Users.sendRegistrationEmail(targetUser);
|
||||
break;
|
||||
default:
|
||||
return fail(403, { errors: ['invalidEmailType'] });
|
||||
}
|
||||
|
||||
// TODO: audit log
|
||||
|
||||
return { errors: [] };
|
||||
},
|
||||
update: async ({ locals, params: { slug: uuid }, request }) => {
|
||||
const { currentUser, userSession } = await UsersAdmin.getActionUser(locals, [
|
||||
'admin',
|
||||
'admin:user'
|
||||
]);
|
||||
const body = await request.formData();
|
||||
|
||||
const { displayName, email, activated, privileges } = Changesets.take<UpdateRequest>(
|
||||
['displayName', 'email', 'activated', 'privileges'],
|
||||
body
|
||||
);
|
||||
|
||||
if (!!privileges && !hasPrivileges(userSession.privileges || [], ['admin:user:privilege'])) {
|
||||
return fail(403, { errors: ['unauthorized'] });
|
||||
}
|
||||
|
||||
const targetUser = await Users.getByUuid(uuid, false);
|
||||
if (!targetUser) {
|
||||
return fail(404, { errors: ['invalidUuid'] });
|
||||
}
|
||||
|
||||
if (currentUser.id === targetUser.id && !activated) {
|
||||
return fail(400, { errors: ['lockout'] });
|
||||
}
|
||||
|
||||
if (privileges) {
|
||||
// TODO: check NaNs
|
||||
const newPrivilegeIds = privileges?.split(',').map(Number) || [];
|
||||
await Users.setUserPrivileges(targetUser, newPrivilegeIds);
|
||||
}
|
||||
|
||||
if (displayName && (displayName.length < 3 || displayName.length > 32)) {
|
||||
return fail(400, { errors: ['invalidDisplayName'] });
|
||||
}
|
||||
|
||||
if (email && !emailRegex.test(email)) {
|
||||
return fail(400, { errors: ['invalidEmail'] });
|
||||
}
|
||||
|
||||
await Users.update(targetUser, {
|
||||
display_name: displayName,
|
||||
email,
|
||||
activated: Number(!!activated),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// TODO: audit log
|
||||
|
||||
return { errors: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export const load = async ({ parent, params }) => {
|
||||
const { user } = await parent();
|
||||
AdminUtils.checkPrivileges(user, ['admin:user']);
|
||||
|
||||
const uuid = params.slug;
|
||||
const userInfo = await UsersAdmin.getUserDetails(uuid);
|
||||
if (!userInfo) {
|
||||
error(404, 'User not found');
|
||||
}
|
||||
|
||||
const privilegeRight = hasPrivileges(user.privileges, ['admin:user:privilege']);
|
||||
const otpEnabled = await TimeOTP.isUserOtp(userInfo);
|
||||
const privileges = privilegeRight ? await Users.getAvailablePrivileges() : [];
|
||||
|
||||
return {
|
||||
privilegeRight,
|
||||
privileges,
|
||||
details: {
|
||||
...userInfo,
|
||||
otpEnabled
|
||||
}
|
||||
};
|
||||
};
|
103
src/routes/ssoadmin/users/[slug]/+page.svelte
Normal file
103
src/routes/ssoadmin/users/[slug]/+page.svelte
Normal file
@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import SplitView from '$lib/components/container/SplitView.svelte';
|
||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||
import FormSection from '$lib/components/form/FormSection.svelte';
|
||||
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||
import AvatarCard from '$lib/components/avatar/AvatarCard.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import AdminPrivilegesSelect from '$lib/components/admin/AdminPrivilegesSelect.svelte';
|
||||
import FormErrors from '$lib/components/form/FormErrors.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
export let form: ActionData;
|
||||
</script>
|
||||
|
||||
<h1>{$t('admin.users.title')} / {data.details.display_name}</h1>
|
||||
|
||||
<SplitView>
|
||||
<ColumnView>
|
||||
<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>
|
||||
{/if}
|
||||
</ColumnView>
|
||||
</AvatarCard>
|
||||
|
||||
<FormErrors errors={form?.errors || []} prefix="admin.users.errors" />
|
||||
|
||||
<form action="?/update" method="POST">
|
||||
<FormWrapper>
|
||||
<FormSection>
|
||||
<FormControl>
|
||||
<label for="user-uuid">{$t('admin.users.uuid')}</label>
|
||||
<input readonly id="user-uuid" value={data.details.uuid} name="uuid" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<label for="user-username">{$t('account.username')}</label>
|
||||
<input readonly id="user-username" value={data.details.username} name="username" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<label for="user-displayName">{$t('account.displayName')}</label>
|
||||
<input id="user-displayName" value={data.details.display_name} name="displayName" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<label for="user-email">{$t('account.email')}</label>
|
||||
<input id="user-email" type="email" value={data.details.email} name="email" />
|
||||
</FormControl>
|
||||
<FormControl>
|
||||
<label for="user-activated">{$t('admin.users.activated')}</label>
|
||||
<input
|
||||
id="user-activated"
|
||||
type="checkbox"
|
||||
checked={Boolean(data.details.activated)}
|
||||
name="activated"
|
||||
/>
|
||||
</FormControl>
|
||||
{#if data.privilegeRight}
|
||||
<AdminPrivilegesSelect available={data.privileges} current={data.details.privileges} />
|
||||
{/if}
|
||||
</FormSection>
|
||||
|
||||
<Button type="submit" variant="primary">{$t('common.submit')}</Button>
|
||||
</FormWrapper>
|
||||
</form>
|
||||
</ColumnView>
|
||||
|
||||
<ColumnView>
|
||||
<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>
|
||||
{/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>
|
||||
{:else}
|
||||
<form action="?/email&type=activate" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.users.activationEmail')}</Button>
|
||||
</form>
|
||||
<form action="?/deleteInfo" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.users.deleteInfo')}</Button>
|
||||
- <span>{$t('admin.users.deleteInfoHint')}</span>
|
||||
</form>
|
||||
{/if}
|
||||
</ColumnView>
|
||||
</SplitView>
|
||||
|
||||
<style>
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
Loading…
Reference in New Issue
Block a user