user management mostly done

This commit is contained in:
Evert Prants 2024-06-01 18:50:36 +03:00
parent 1f5de32f61
commit d258880ac4
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
19 changed files with 622 additions and 102 deletions

5
src/app.d.ts vendored
View File

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

View File

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

View 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}>&lt;&lt;</Button>
<Button variant="primary" on:click={transferToCurrent}>&gt;&gt;</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>

View File

@ -67,6 +67,14 @@
display: block;
padding: 8px 16px;
text-decoration: none;
&.active {
background-color: #c7c7c7;
}
&:hover {
background-color: #e4e4e4;
}
}
}
}

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

View File

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

View File

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

View File

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

View File

@ -13,5 +13,7 @@
"bool": {
"true": "Yes",
"false": "No"
}
},
"available": "Available",
"current": "Current"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,6 +16,7 @@ export const load = async ({ url, locals }) => {
}
return {
renderrt: Date.now(),
user: {
...userInfo,
privileges

View File

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

View File

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

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

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