Compare commits
2 Commits
master
...
ip-managem
Author | SHA1 | Date | |
---|---|---|---|
c5c85bf771 | |||
088417af73 |
34
migrations/0008_faithful_golden_guardian.sql
Normal file
34
migrations/0008_faithful_golden_guardian.sql
Normal file
@ -0,0 +1,34 @@
|
||||
CREATE TABLE `ip_address` (
|
||||
`id` int unsigned AUTO_INCREMENT NOT NULL,
|
||||
`ip_address` int unsigned NOT NULL,
|
||||
`flags` tinyint unsigned NOT NULL,
|
||||
`listId` int unsigned,
|
||||
`created_at` datetime(6) NOT NULL DEFAULT current_timestamp(6),
|
||||
`updated_at` datetime(6) NOT NULL DEFAULT current_timestamp(6),
|
||||
CONSTRAINT `ip_address_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `ip_address_idx` UNIQUE(`ip_address`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `ip_list` (
|
||||
`id` int unsigned AUTO_INCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`url` text,
|
||||
`default_flags` tinyint unsigned NOT NULL,
|
||||
`created_at` datetime(6) NOT NULL DEFAULT current_timestamp(6),
|
||||
`updated_at` datetime(6) NOT NULL DEFAULT current_timestamp(6),
|
||||
CONSTRAINT `ip_list_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `ip_address_user` (
|
||||
`id` int unsigned AUTO_INCREMENT NOT NULL,
|
||||
`userId` int,
|
||||
`ipAddressId` int unsigned,
|
||||
CONSTRAINT `ip_address_user_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `audit_log` ADD `ipAddressId` int unsigned;--> statement-breakpoint
|
||||
ALTER TABLE `ip_address` ADD CONSTRAINT `ip_address_listId_ip_list_id_fk` FOREIGN KEY (`listId`) REFERENCES `ip_list`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `ip_address_user` ADD CONSTRAINT `ip_address_user_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE `ip_address_user` ADD CONSTRAINT `ip_address_user_ipAddressId_ip_address_id_fk` FOREIGN KEY (`ipAddressId`) REFERENCES `ip_address`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX `ip_flags_idx` ON `ip_address` (`flags`);--> statement-breakpoint
|
||||
ALTER TABLE `audit_log` ADD CONSTRAINT `audit_log_ipAddressId_ip_address_id_fk` FOREIGN KEY (`ipAddressId`) REFERENCES `ip_address`(`id`) ON DELETE set null ON UPDATE no action;
|
1
migrations/0009_broad_kylun.sql
Normal file
1
migrations/0009_broad_kylun.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE `ip_address` MODIFY COLUMN `ip_address` varbinary(16) NOT NULL;
|
1422
migrations/meta/0008_snapshot.json
Normal file
1422
migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1422
migrations/meta/0009_snapshot.json
Normal file
1422
migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -57,6 +57,20 @@
|
||||
"when": 1739896136734,
|
||||
"tag": "0007_slim_dexter_bennett",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "5",
|
||||
"when": 1740393886197,
|
||||
"tag": "0008_faithful_golden_guardian",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "5",
|
||||
"when": 1740394195704,
|
||||
"tag": "0009_broad_kylun",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
@ -7,9 +7,11 @@
|
||||
|
||||
interface Props {
|
||||
audit: PageData['list'][0];
|
||||
ipLink?: boolean;
|
||||
userLink?: boolean;
|
||||
}
|
||||
|
||||
let { audit }: Props = $props();
|
||||
let { audit, ipLink, userLink }: Props = $props();
|
||||
let expanded = $state(false);
|
||||
</script>
|
||||
|
||||
@ -26,11 +28,21 @@
|
||||
<dd>{audit.action}</dd>
|
||||
{#if audit.user}
|
||||
<dt>{$t('admin.audit.user')}</dt>
|
||||
<dd>{audit.user.uuid} ({audit.user.name})</dd>
|
||||
<dd>
|
||||
{audit.user.uuid} ({audit.user.name})
|
||||
{#if userLink}
|
||||
(<a href="/ssoadmin/users/{audit.user.uuid}">{$t('admin.audit.userLookup')}</a>)
|
||||
{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
{#if audit.ip}
|
||||
<dt>{$t('admin.audit.ip')}</dt>
|
||||
<dd>{audit.ip}</dd>
|
||||
<dd>
|
||||
{audit.ip}
|
||||
{#if ipLink}
|
||||
(<a href="/ssoadmin/addresses?ip={audit.ip}">{$t('admin.audit.ipLookup')}</a>)
|
||||
{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
{#if audit.ua}
|
||||
<dt>{$t('admin.audit.ua')}</dt>
|
||||
|
68
src/lib/components/admin/AdminIpTable.svelte
Normal file
68
src/lib/components/admin/AdminIpTable.svelte
Normal file
@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import type { IPAddressListEntry } from '$lib/server/ip/types';
|
||||
import { t } from '$lib/i18n';
|
||||
import AdminDateTime from './AdminDateTime.svelte';
|
||||
|
||||
let {
|
||||
list,
|
||||
hasAuditPermission,
|
||||
unpackFlags
|
||||
}: {
|
||||
list: IPAddressListEntry[];
|
||||
unpackFlags: (num: number) => string[];
|
||||
hasAuditPermission?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<table class="address-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{$t('admin.ip.column.ip')}</td>
|
||||
<td>{$t('admin.ip.column.flags')}</td>
|
||||
<td>{$t('admin.ip.column.created_at')}</td>
|
||||
<td>{$t('admin.ip.column.updated_at')}</td>
|
||||
<td>{$t('admin.ip.column.user_count')}</td>
|
||||
<td>{$t('admin.ip.column.list')}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#each list as address}
|
||||
<tr>
|
||||
<td><a href="/ssoadmin/addresses?ip={address.ip_address}">{address.ip_address}</a></td>
|
||||
<td>{unpackFlags(address.flags)}</td>
|
||||
<td><AdminDateTime date={address.created_at} /></td>
|
||||
<td><AdminDateTime date={address.updated_at} /></td>
|
||||
<td>{address.user_count}</td>
|
||||
<td>{address.list?.name || '-'}</td>
|
||||
<td>
|
||||
{#if hasAuditPermission}
|
||||
<a href="/ssoadmin/audit?ip={address.ip_address}">{$t('admin.audit.title')}</a>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{#if address.users?.length}
|
||||
<tr>
|
||||
<td colspan="7">
|
||||
{$t('admin.ip.users')}:
|
||||
{#each address.users as user}
|
||||
<a href="/ssoadmin/users/{user.uuid}">{user.display_name}</a>
|
||||
{/each}
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<style>
|
||||
.address-table {
|
||||
width: 100%;
|
||||
|
||||
tr > td {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -27,6 +27,11 @@
|
||||
href: '/ssoadmin/audit',
|
||||
title: $t('admin.menu.audit'),
|
||||
privileges: ['admin:audit']
|
||||
},
|
||||
{
|
||||
href: '/ssoadmin/addresses',
|
||||
title: $t('admin.menu.ip'),
|
||||
privileges: ['admin:ip']
|
||||
}
|
||||
];
|
||||
|
||||
|
@ -6,7 +6,8 @@
|
||||
"close": "Close menu",
|
||||
"users": "Users",
|
||||
"oauth2": "OAuth 2.0 applications",
|
||||
"audit": "Audit logs"
|
||||
"audit": "Audit logs",
|
||||
"ip": "IP addresses"
|
||||
},
|
||||
"users": {
|
||||
"title": "Users",
|
||||
@ -144,12 +145,27 @@
|
||||
"invalidJwks": "Invalid JSON Web Keys provided, please check your input and try again."
|
||||
}
|
||||
},
|
||||
"ip": {
|
||||
"title": "IP Addresses",
|
||||
"user": "Associated user",
|
||||
"users": "Associated users",
|
||||
"column": {
|
||||
"ip": "IP address",
|
||||
"flags": "Flags",
|
||||
"created_at": "First seen at",
|
||||
"updated_at": "Last seen at",
|
||||
"user_count": "User count",
|
||||
"list": "List"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit logs",
|
||||
"action": "Action",
|
||||
"comment": "Comment",
|
||||
"user": "Actor",
|
||||
"ip": "IP address",
|
||||
"userLookup": "View user info",
|
||||
"ipLookup": "View IP info",
|
||||
"ua": "User agent",
|
||||
"flagged": "Flagged",
|
||||
"createdAt": "Created at"
|
||||
|
@ -1,5 +1,13 @@
|
||||
import { SQL, count, desc, eq, inArray, or, sql } from 'drizzle-orm';
|
||||
import { DB, auditLog, user, type AuditLog, type NewAuditLog, type User } from '../drizzle';
|
||||
import {
|
||||
DB,
|
||||
auditLog,
|
||||
ipAddress,
|
||||
user,
|
||||
type AuditLog,
|
||||
type NewAuditLog,
|
||||
type User
|
||||
} from '../drizzle';
|
||||
import {
|
||||
AuditAction,
|
||||
type AuditListItem,
|
||||
@ -12,6 +20,7 @@ import { AdminNotificationEmail, Emails } from '../email';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { env as publicEnv } from '$env/dynamic/public';
|
||||
import { CacheBackend } from '../cache-backend';
|
||||
import { IPAddresses, IPFlag } from '../ip';
|
||||
|
||||
const FLAG_EMAIL_COOLDOWN = 1 * 60 * 1000;
|
||||
const FLAG_TRESHOLD_COOLDOWN = 30 * 60 * 1000;
|
||||
@ -34,7 +43,6 @@ export class Audit {
|
||||
action,
|
||||
content: comment,
|
||||
actorId: user?.id,
|
||||
actor_ip: ip,
|
||||
actor_ua: userAgent,
|
||||
flagged: Number(flagged)
|
||||
};
|
||||
@ -47,6 +55,25 @@ export class Audit {
|
||||
Audit.auditFlagTrigger();
|
||||
}
|
||||
|
||||
if (ip) {
|
||||
const ipFlags: IPFlag[] = [];
|
||||
|
||||
if (flagged) {
|
||||
ipFlags.push(IPFlag.FLAGGED);
|
||||
}
|
||||
|
||||
if (action === AuditAction.LOGIN) {
|
||||
ipFlags.push(IPFlag.USER_IP);
|
||||
}
|
||||
|
||||
newAuditLog.ipAddressId = await IPAddresses.storeOrUpdateIpAddress(
|
||||
ip,
|
||||
ipFlags,
|
||||
undefined,
|
||||
user?.id
|
||||
);
|
||||
}
|
||||
|
||||
await DB.drizzle.insert(auditLog).values(newAuditLog);
|
||||
}
|
||||
|
||||
@ -67,6 +94,7 @@ export class Audit {
|
||||
.select({ rowCount: count(auditLog.id).mapWith(Number) })
|
||||
.from(auditLog)
|
||||
.leftJoin(user, eq(user.id, auditLog.actorId))
|
||||
.leftJoin(ipAddress, eq(ipAddress.id, auditLog.ipAddressId))
|
||||
.where(Audit.getAuditWhere(search));
|
||||
return rowCount;
|
||||
}
|
||||
@ -84,6 +112,7 @@ export class Audit {
|
||||
.select({ id: auditLog.id })
|
||||
.from(auditLog)
|
||||
.leftJoin(user, eq(user.id, auditLog.actorId))
|
||||
.leftJoin(ipAddress, eq(ipAddress.id, auditLog.ipAddressId))
|
||||
.limit(limit)
|
||||
.offset(search.offset || 0)
|
||||
.where(Audit.getAuditWhere(search))
|
||||
@ -93,10 +122,12 @@ export class Audit {
|
||||
const junkList = await DB.drizzle
|
||||
.select({
|
||||
audit_log: auditLog,
|
||||
ip_address: sql<string>`INET6_NTOA(${ipAddress.ip_address})`,
|
||||
user: user
|
||||
})
|
||||
.from(auditSubquery)
|
||||
.innerJoin(auditLog, eq(auditLog.id, auditSubquery.id))
|
||||
.leftJoin(ipAddress, eq(ipAddress.id, auditLog.ipAddressId))
|
||||
.leftJoin(user, eq(user.id, auditLog.actorId));
|
||||
|
||||
const list = Audit.mapAuditRows(junkList);
|
||||
@ -124,6 +155,7 @@ export class Audit {
|
||||
|
||||
if (search.ip) {
|
||||
selectList.push(sql`lower(${auditLog.actor_ip}) LIKE ${`%${search.ip.toLowerCase()}%`}`);
|
||||
selectList.push(sql`${ipAddress.ip_address} = INET6_ATON(${search.ip})`);
|
||||
}
|
||||
|
||||
if (search.ua) {
|
||||
@ -144,6 +176,7 @@ export class Audit {
|
||||
private static mapAuditRows(
|
||||
rows: {
|
||||
audit_log: AuditLog;
|
||||
ip_address?: string;
|
||||
user?: User | null;
|
||||
}[]
|
||||
) {
|
||||
@ -153,7 +186,7 @@ export class Audit {
|
||||
existingEntry = {
|
||||
id: entry.audit_log.id,
|
||||
action: entry.audit_log.action as AuditAction,
|
||||
ip: entry.audit_log.actor_ip || undefined,
|
||||
ip: entry.ip_address || entry.audit_log.actor_ip || undefined,
|
||||
ua: entry.audit_log.actor_ua || undefined,
|
||||
content: entry.audit_log.content || undefined,
|
||||
flagged: Boolean(entry.audit_log.flagged),
|
||||
@ -169,6 +202,10 @@ export class Audit {
|
||||
};
|
||||
}
|
||||
|
||||
if (entry.ip_address) {
|
||||
existingEntry.ip = entry.ip_address;
|
||||
}
|
||||
|
||||
return accum;
|
||||
}, []);
|
||||
}
|
||||
|
@ -11,10 +11,59 @@ import {
|
||||
mysqlEnum,
|
||||
index,
|
||||
type AnyMySqlColumn,
|
||||
json
|
||||
json,
|
||||
uniqueIndex,
|
||||
varbinary
|
||||
} from 'drizzle-orm/mysql-core';
|
||||
import type { JWK } from 'jose';
|
||||
|
||||
export const ipList = mysqlTable('ip_list', {
|
||||
id: int('id', { unsigned: true }).autoincrement().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
url: text('url'),
|
||||
default_flags: tinyint('default_flags', { unsigned: true }).notNull(),
|
||||
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
|
||||
.default(sql`current_timestamp(6)`)
|
||||
.notNull(),
|
||||
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
|
||||
.default(sql`current_timestamp(6)`)
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export type IPList = typeof ipList.$inferSelect;
|
||||
export type NewIPList = typeof ipList.$inferInsert;
|
||||
|
||||
export const ipAddress = mysqlTable(
|
||||
'ip_address',
|
||||
{
|
||||
id: int('id', { unsigned: true }).autoincrement().primaryKey(),
|
||||
ip_address: varbinary('ip_address', { length: 16 }).notNull(),
|
||||
flags: tinyint('flags', { unsigned: true }).notNull(),
|
||||
listId: int('listId', { unsigned: true }).references(() => ipList.id, { onDelete: 'cascade' }),
|
||||
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
|
||||
.default(sql`current_timestamp(6)`)
|
||||
.notNull(),
|
||||
updated_at: datetime('updated_at', { mode: 'date', fsp: 6 })
|
||||
.default(sql`current_timestamp(6)`)
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('ip_address_idx').on(table.ip_address),
|
||||
index('ip_flags_idx').on(table.flags)
|
||||
]
|
||||
);
|
||||
|
||||
export type IPAddress = typeof ipAddress.$inferSelect;
|
||||
export type NewIPAddress = typeof ipAddress.$inferInsert;
|
||||
|
||||
export const ipUser = mysqlTable('ip_address_user', {
|
||||
id: int('id', { unsigned: true }).autoincrement().primaryKey(),
|
||||
userId: int('userId').references(() => user.id, { onDelete: 'cascade' }),
|
||||
ipAddressId: int('ipAddressId', { unsigned: true }).references(() => ipAddress.id, {
|
||||
onDelete: 'cascade'
|
||||
})
|
||||
});
|
||||
|
||||
export const jwks = mysqlTable('jwks', {
|
||||
uuid: varchar('uuid', { length: 36 }).primaryKey(),
|
||||
fingerprint: varchar('fingerprint', { length: 64 }).notNull(),
|
||||
@ -39,7 +88,10 @@ export const auditLog = mysqlTable('audit_log', {
|
||||
created_at: datetime('created_at', { mode: 'date', fsp: 6 })
|
||||
.default(sql`current_timestamp(6)`)
|
||||
.notNull(),
|
||||
actorId: int('actorId').references(() => user.id, { onDelete: 'set null' })
|
||||
actorId: int('actorId').references(() => user.id, { onDelete: 'set null' }),
|
||||
ipAddressId: int('ipAddressId', { unsigned: true }).references(() => ipAddress.id, {
|
||||
onDelete: 'set null'
|
||||
})
|
||||
});
|
||||
|
||||
export type AuditLog = typeof auditLog.$inferSelect;
|
||||
@ -286,6 +338,10 @@ export const auditLogRelations = relations(auditLog, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [auditLog.actorId],
|
||||
references: [user.id]
|
||||
}),
|
||||
ip_address: one(ipAddress, {
|
||||
fields: [auditLog.ipAddressId],
|
||||
references: [ipAddress.id]
|
||||
})
|
||||
}));
|
||||
|
||||
@ -413,3 +469,21 @@ export const userTokenRelations = relations(userToken, ({ one }) => ({
|
||||
references: [user.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const ipAddressRelations = relations(ipAddress, ({ one }) => ({
|
||||
list: one(ipList, {
|
||||
fields: [ipAddress.listId],
|
||||
references: [ipList.id]
|
||||
})
|
||||
}));
|
||||
|
||||
export const ipAddressUserRelations = relations(ipUser, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [ipUser.userId],
|
||||
references: [user.id]
|
||||
}),
|
||||
ipAddress: one(ipAddress, {
|
||||
fields: [ipUser.ipAddressId],
|
||||
references: [ipAddress.id]
|
||||
})
|
||||
}));
|
||||
|
@ -11,6 +11,7 @@ const privileges = [
|
||||
'admin:oauth2',
|
||||
'admin:audit',
|
||||
'admin:document',
|
||||
'admin:ip',
|
||||
'self:oauth2',
|
||||
'self:oauth2:implicit',
|
||||
'self:oauth2:create'
|
||||
|
103
src/lib/server/ip/admin.ts
Normal file
103
src/lib/server/ip/admin.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { and, count, countDistinct, desc, eq, sql, type SQL } from 'drizzle-orm';
|
||||
import type { IPAddressListEntry, IPAddressQuery } from './types';
|
||||
import { DB, ipAddress, ipList, ipUser, user } from '../drizzle';
|
||||
import type { PaginationMeta, Paginated } from '$lib/types';
|
||||
|
||||
export class IPAddressesAdmin {
|
||||
static async filterSearchIpAddresses(
|
||||
search: IPAddressQuery & {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
) {
|
||||
const limit = search.limit || 20;
|
||||
const rowCount = await IPAddressesAdmin.ipCount(search);
|
||||
|
||||
const ipAddressSubquery = DB.drizzle
|
||||
.select({ id: ipAddress.id })
|
||||
.from(ipAddress)
|
||||
.leftJoin(ipUser, eq(ipAddress.id, ipUser.ipAddressId))
|
||||
.leftJoin(user, eq(user.id, ipUser.userId))
|
||||
.limit(limit)
|
||||
.offset(search.offset || 0)
|
||||
.where(IPAddressesAdmin.getIpAddressWhere(search))
|
||||
.orderBy(desc(ipAddress.updated_at))
|
||||
.as('ipAddressSubquery');
|
||||
|
||||
const list: IPAddressListEntry[] = await DB.drizzle
|
||||
.select({
|
||||
id: ipAddress.id,
|
||||
ip_address: sql<string>`INET6_NTOA(${ipAddress.ip_address})`,
|
||||
flags: ipAddress.flags,
|
||||
created_at: ipAddress.created_at,
|
||||
updated_at: ipAddress.updated_at,
|
||||
user_count: countDistinct(user.id),
|
||||
list: {
|
||||
id: ipList.id,
|
||||
name: ipList.name
|
||||
}
|
||||
})
|
||||
.from(ipAddressSubquery)
|
||||
.innerJoin(ipAddress, eq(ipAddress.id, ipAddressSubquery.id))
|
||||
.leftJoin(ipList, eq(ipAddress.listId, ipList.id))
|
||||
.leftJoin(ipUser, eq(ipAddress.id, ipUser.ipAddressId))
|
||||
.leftJoin(user, eq(user.id, ipUser.userId))
|
||||
.orderBy(desc(ipAddress.updated_at))
|
||||
.groupBy(ipAddress.id);
|
||||
|
||||
const meta: PaginationMeta = {
|
||||
rowCount,
|
||||
pageSize: limit,
|
||||
pageCount: Math.ceil(rowCount / limit)
|
||||
};
|
||||
|
||||
if (search.includeUsers) {
|
||||
await Promise.all(
|
||||
list.map(async (entry) => {
|
||||
entry.users = await DB.drizzle
|
||||
.select({
|
||||
uuid: user.uuid,
|
||||
display_name: user.display_name
|
||||
})
|
||||
.from(user)
|
||||
.innerJoin(ipUser, eq(user.id, ipUser.userId))
|
||||
.where(eq(ipUser.ipAddressId, entry.id));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return { list, meta } satisfies Paginated<IPAddressListEntry>;
|
||||
}
|
||||
|
||||
static async ipCount(search: IPAddressQuery) {
|
||||
const [{ rowCount }] = await DB.drizzle
|
||||
.select({ rowCount: count(ipAddress.id).mapWith(Number) })
|
||||
.from(ipAddress)
|
||||
.leftJoin(ipUser, eq(ipUser.ipAddressId, ipAddress.id))
|
||||
.leftJoin(user, eq(user.id, ipUser.userId))
|
||||
.where(IPAddressesAdmin.getIpAddressWhere(search));
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
static getIpAddressWhere(search: IPAddressQuery) {
|
||||
const selectList: SQL<unknown>[] = [];
|
||||
|
||||
if (search.ip) {
|
||||
selectList.push(sql`${ipAddress.ip_address} = INET6_ATON(${search.ip})`);
|
||||
}
|
||||
|
||||
if (search.flags) {
|
||||
selectList.push(sql`${ipAddress.flags} & ${Number(search.flags)}`);
|
||||
}
|
||||
|
||||
if (search.user) {
|
||||
selectList.push(eq(user.uuid, search.user));
|
||||
}
|
||||
|
||||
if (search.listId) {
|
||||
selectList.push(eq(ipAddress.listId, Number(search.listId)));
|
||||
}
|
||||
|
||||
return and(...selectList);
|
||||
}
|
||||
}
|
114
src/lib/server/ip/index.ts
Normal file
114
src/lib/server/ip/index.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { DB, ipAddress, ipList, ipUser } from '../drizzle';
|
||||
import { CacheBackend } from '../cache-backend';
|
||||
import { IPFlag } from './types';
|
||||
import { ensureArray } from '$lib/utils';
|
||||
|
||||
export class IPAddresses {
|
||||
static flagsList = Object.values(IPFlag).filter((entry) => isNaN(Number(entry))) as string[];
|
||||
|
||||
static async getIpAddressesByFlag(flag: IPFlag | IPFlag[]) {
|
||||
const flags = ensureArray(flag);
|
||||
const combinedFlag = flags.reduce<number>((num, flag) => num | (1 << flag), 0);
|
||||
const ipAddresses = await DB.drizzle
|
||||
.select({ ip_address: sql<string>`INET6_NTOA(${ipAddress.ip_address})` })
|
||||
.from(ipAddress)
|
||||
.where(sql`${ipAddress.flags} & ${combinedFlag}`);
|
||||
return ipAddresses.map(({ ip_address }) => ip_address);
|
||||
}
|
||||
|
||||
static async getIpAddressesByUserId(userId: number) {
|
||||
return DB.drizzle
|
||||
.select({
|
||||
id: ipAddress.id,
|
||||
ip_address: sql<string>`INET6_NTOA(${ipAddress.ip_address})`,
|
||||
flags: ipAddress.flags,
|
||||
updated_at: ipAddress.updated_at,
|
||||
list: {
|
||||
id: ipList.id,
|
||||
name: ipList.name
|
||||
}
|
||||
})
|
||||
.from(ipAddress)
|
||||
.leftJoin(ipUser, eq(ipAddress.id, ipUser.ipAddressId))
|
||||
.leftJoin(ipList, eq(ipList.id, ipAddress.listId))
|
||||
.where(eq(ipUser.userId, userId));
|
||||
}
|
||||
|
||||
static async getCachedIpAddressesByFlag(flag: IPFlag) {
|
||||
const existing = await CacheBackend.get<string[]>(`ipAddressList${flag}`);
|
||||
if (!existing) {
|
||||
const result = await IPAddresses.getIpAddressesByFlag(flag);
|
||||
await CacheBackend.set(`ipAddressList${flag}`, result, 60 * 60 * 1000); // 1 hour cache
|
||||
return result;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
static async isIpFlagged(address: string, flag: IPFlag) {
|
||||
const [entry] = await DB.drizzle
|
||||
.select()
|
||||
.from(ipAddress)
|
||||
.where(
|
||||
and(
|
||||
sql`${ipAddress.ip_address} = INET6_ATON(${address})`,
|
||||
sql`${ipAddress.flags} & ${1 << flag}`
|
||||
)
|
||||
);
|
||||
return !!entry;
|
||||
}
|
||||
|
||||
static async storeOrUpdateIpAddress(
|
||||
address: string,
|
||||
flags: IPFlag[],
|
||||
listId?: number,
|
||||
userId?: number
|
||||
) {
|
||||
let updateId: number;
|
||||
const combinedFlag = flags.reduce<number>((num, flag) => num | (1 << flag), 0);
|
||||
const [existingIp] = await DB.drizzle
|
||||
.select()
|
||||
.from(ipAddress)
|
||||
.where(sql`${ipAddress.ip_address} = INET6_ATON(${address})`);
|
||||
|
||||
if (existingIp) {
|
||||
updateId = existingIp.id;
|
||||
existingIp.flags = existingIp.flags | combinedFlag;
|
||||
existingIp.updated_at = new Date();
|
||||
if (listId) {
|
||||
existingIp.listId = listId || null;
|
||||
}
|
||||
await DB.drizzle.update(ipAddress).set(existingIp).where(eq(ipAddress.id, existingIp.id));
|
||||
} else {
|
||||
const [result] = await DB.drizzle
|
||||
.insert(ipAddress)
|
||||
.values({
|
||||
ip_address: sql`INET6_ATON(${address})`,
|
||||
flags: combinedFlag,
|
||||
listId: listId || null
|
||||
})
|
||||
.$returningId();
|
||||
updateId = result.id;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const [relationExists] = await DB.drizzle
|
||||
.select()
|
||||
.from(ipUser)
|
||||
.where(and(eq(ipUser.userId, userId), eq(ipUser.ipAddressId, updateId)));
|
||||
|
||||
if (relationExists) {
|
||||
return updateId;
|
||||
}
|
||||
|
||||
await DB.drizzle.insert(ipUser).values({
|
||||
ipAddressId: updateId,
|
||||
userId
|
||||
});
|
||||
}
|
||||
|
||||
return updateId;
|
||||
}
|
||||
}
|
||||
|
||||
export { IPFlag };
|
32
src/lib/server/ip/types.ts
Normal file
32
src/lib/server/ip/types.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export enum IPFlag {
|
||||
FLAGGED = 0,
|
||||
USER_IP,
|
||||
RESTRICT_UPLOAD,
|
||||
RESTRICT_REGISTRATION,
|
||||
REJECT
|
||||
}
|
||||
|
||||
export interface IPAddressQuery {
|
||||
ip?: string;
|
||||
flags?: number;
|
||||
user?: string;
|
||||
listId?: number;
|
||||
includeUsers?: boolean;
|
||||
}
|
||||
|
||||
export interface IPAddressListEntry {
|
||||
id: number;
|
||||
ip_address: string;
|
||||
flags: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
list: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
user_count: number;
|
||||
users?: {
|
||||
uuid: string;
|
||||
display_name: string;
|
||||
}[];
|
||||
}
|
@ -2,11 +2,12 @@ import { Audit } from '$lib/server/audit/audit.js';
|
||||
import { AuditAction } from '$lib/server/audit/types.js';
|
||||
import { Challenge } from '$lib/server/challenge.js';
|
||||
import type { User } from '$lib/server/drizzle';
|
||||
import { IPAddresses, IPFlag } from '$lib/server/ip';
|
||||
import { Uploads } from '$lib/server/upload.js';
|
||||
import { Users, type UserSession } from '$lib/server/users/index.js';
|
||||
import { TimeOTP } from '$lib/server/users/totp.js';
|
||||
import { passwordRegex } from '$lib/validators.js';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
|
||||
interface AccountUpdate {
|
||||
displayName: string;
|
||||
@ -166,6 +167,11 @@ export const actions = {
|
||||
return redirect(303, '/login');
|
||||
}
|
||||
|
||||
// IP banned from uploading files
|
||||
if (await IPAddresses.isIpFlagged(getClientAddress(), IPFlag.RESTRICT_UPLOAD)) {
|
||||
throw error(403);
|
||||
}
|
||||
|
||||
const formData = Object.fromEntries(await request.formData());
|
||||
if (!(formData.file as File)?.name || (formData.file as File).name === 'undefined') {
|
||||
return fail(400, {
|
||||
|
15
src/routes/api/server/reject.txt/+server.ts
Normal file
15
src/routes/api/server/reject.txt/+server.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { IPAddresses, IPFlag } from '$lib/server/ip';
|
||||
|
||||
export const GET = () =>
|
||||
IPAddresses.getCachedIpAddressesByFlag(IPFlag.REJECT)
|
||||
.then(
|
||||
(list) =>
|
||||
new Response(list.join('\n'), { status: 200, headers: { 'Content-Type': 'text/plain' } })
|
||||
)
|
||||
.catch(
|
||||
() =>
|
||||
new Response('Internal Server Error', {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'text/plain' }
|
||||
})
|
||||
);
|
@ -19,7 +19,7 @@ const rainbowTableLimiter = new RateLimiter({
|
||||
});
|
||||
|
||||
const limiter = new RateLimiter({
|
||||
IP: [6, 'm']
|
||||
IP: [8, 'm']
|
||||
});
|
||||
|
||||
export const actions = {
|
||||
|
@ -1,16 +1,12 @@
|
||||
import { Audit, AuditAction } from '$lib/server/audit';
|
||||
import { Changesets } from '$lib/server/changesets.js';
|
||||
import { IPAddresses, IPFlag } from '$lib/server/ip';
|
||||
import { Users } from '$lib/server/users/index.js';
|
||||
import { UserTokens } from '$lib/server/users/tokens.js';
|
||||
import { emailRegex, passwordRegex } from '$lib/validators.js';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { RateLimiter } from 'sveltekit-rate-limiter/server';
|
||||
|
||||
interface PasswordRequest {
|
||||
newPassword: string;
|
||||
repeatPassword: string;
|
||||
}
|
||||
|
||||
// Sending an email asynchronously has a similar amount of delay,
|
||||
// so lets fake it. TODO: offload email sending somewhere else.
|
||||
const failDelay = () =>
|
||||
@ -28,6 +24,11 @@ export const actions = {
|
||||
throw error(429);
|
||||
}
|
||||
|
||||
// IP banned
|
||||
if (await IPAddresses.isIpFlagged(event.getClientAddress(), IPFlag.RESTRICT_REGISTRATION)) {
|
||||
throw error(403);
|
||||
}
|
||||
|
||||
if (locals.session.data?.user) {
|
||||
return redirect(303, '/');
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { env } from '$env/dynamic/private';
|
||||
import { AuditAction } from '$lib/server/audit';
|
||||
import { Audit } from '$lib/server/audit/audit.js';
|
||||
import { Changesets } from '$lib/server/changesets.js';
|
||||
import { IPAddresses, IPFlag } from '$lib/server/ip/index.js';
|
||||
import { Users } from '$lib/server/users/index.js';
|
||||
import { emailRegex, passwordRegex, usernameRegex } from '$lib/validators.js';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
@ -30,7 +31,16 @@ const limiter = new RateLimiter({
|
||||
export const actions = {
|
||||
default: async (event) => {
|
||||
const { request, locals } = event;
|
||||
if (await limiter.isLimited(event)) throw error(429);
|
||||
|
||||
// Rate limited
|
||||
if (await limiter.isLimited(event)) {
|
||||
throw error(429);
|
||||
}
|
||||
|
||||
// IP banned
|
||||
if (await IPAddresses.isIpFlagged(event.getClientAddress(), IPFlag.RESTRICT_REGISTRATION)) {
|
||||
throw error(403);
|
||||
}
|
||||
|
||||
// Logged in users cannot make more accounts
|
||||
if (locals.session.data?.user || env.REGISTRATIONS === 'false') {
|
||||
|
39
src/routes/ssoadmin/addresses/+page.server.ts
Normal file
39
src/routes/ssoadmin/addresses/+page.server.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { AdminUtils } from '$lib/server/admin-utils';
|
||||
import { Changesets } from '$lib/server/changesets';
|
||||
import { IPAddressesAdmin } from '$lib/server/ip/admin';
|
||||
import { IPAddresses } from '$lib/server/ip/index.js';
|
||||
import { hasPrivileges } from '$lib/utils.js';
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export const load = async ({ parent, url }) => {
|
||||
const { user } = await parent();
|
||||
AdminUtils.checkPrivileges(user, ['admin:ip']);
|
||||
|
||||
const {
|
||||
page,
|
||||
pageSize,
|
||||
ip,
|
||||
flags,
|
||||
user: listUser,
|
||||
listId
|
||||
} = Changesets.only(['page', 'pageSize', 'user', 'flags', 'ip', 'listId'], url.searchParams);
|
||||
|
||||
const limit = Number(pageSize) || PAGE_SIZE;
|
||||
const offset = ((Number(page) || 1) - 1) * limit;
|
||||
|
||||
const data = await IPAddressesAdmin.filterSearchIpAddresses({
|
||||
limit,
|
||||
offset,
|
||||
ip,
|
||||
includeUsers: !!ip && hasPrivileges(user.privileges, ['admin:user']),
|
||||
user: listUser,
|
||||
flags: flags ? Number(flags) : undefined,
|
||||
listId: listId ? Number(listId) : undefined
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
flags: IPAddresses.flagsList
|
||||
};
|
||||
};
|
93
src/routes/ssoadmin/addresses/+page.svelte
Normal file
93
src/routes/ssoadmin/addresses/+page.svelte
Normal file
@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import type { PageData } from './$types';
|
||||
import { t } from '$lib/i18n';
|
||||
import ColumnView from '$lib/components/container/ColumnView.svelte';
|
||||
import Paginator from '$lib/components/Paginator.svelte';
|
||||
import { page } from '$app/state';
|
||||
import { hasPrivileges } from '$lib/utils';
|
||||
import AdminIpTable from '$lib/components/admin/AdminIpTable.svelte';
|
||||
import SplitView from '$lib/components/container/SplitView.svelte';
|
||||
import FormControl from '$lib/components/form/FormControl.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import FormActions from '$lib/components/form/FormActions.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const hasAuditPermission = $derived(hasPrivileges(data.user.privileges, ['admin:audit']));
|
||||
|
||||
const unpackFlags = (flags: number) =>
|
||||
data.flags.reduce<string[]>((list, key, index) => {
|
||||
if (flags & (1 << index)) {
|
||||
list.push(key);
|
||||
}
|
||||
return list;
|
||||
}, []);
|
||||
|
||||
const packFlags = (flags: string[]) =>
|
||||
flags.reduce<number>((flag, key) => flag | (1 << data.flags.indexOf(key)), 0);
|
||||
|
||||
const currentSearchFlags = $derived(
|
||||
page.url.searchParams.has('flags')
|
||||
? unpackFlags(Number(page.url.searchParams.get('flags')))
|
||||
: []
|
||||
);
|
||||
|
||||
let flagsState = $state(0);
|
||||
const updateFlagsState = (e: Event) => {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
const selection = Array.from(target.selectedOptions).map(({ value }) => value);
|
||||
flagsState = packFlags(selection);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
flagsState = packFlags(currentSearchFlags);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('admin.ip.title')} - {env.PUBLIC_SITE_NAME} {$t('admin.title')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{$t('admin.ip.title')} ({data.meta.rowCount})</h1>
|
||||
|
||||
<ColumnView>
|
||||
<form action="" method="get">
|
||||
<ColumnView>
|
||||
<SplitView>
|
||||
<FormControl>
|
||||
<label for="ip">{$t('admin.ip.column.ip')}</label>
|
||||
<input name="ip" id="ip" value={page.url.searchParams.get('ip')} />
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<label for="user">{$t('admin.ip.user')}</label>
|
||||
<input name="user" id="user" value={page.url.searchParams.get('user')} />
|
||||
</FormControl>
|
||||
</SplitView>
|
||||
|
||||
<SplitView>
|
||||
<FormControl>
|
||||
<input name="flags" value={flagsState} type="hidden" />
|
||||
<label for="flagselect">{$t('admin.ip.column.flags')}</label>
|
||||
<select id="flagselect" value={currentSearchFlags} multiple onchange={updateFlagsState}>
|
||||
{#each data.flags as flag}
|
||||
<option value={flag}>{flag}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</FormControl>
|
||||
</SplitView>
|
||||
|
||||
<FormActions padded>
|
||||
<a href="/ssoadmin/addresses">{$t('common.clear')}</a>
|
||||
<Button type="submit">{$t('common.filter')}</Button>
|
||||
</FormActions>
|
||||
</ColumnView>
|
||||
</form>
|
||||
|
||||
<Paginator meta={data.meta} />
|
||||
|
||||
<AdminIpTable list={data.list} {hasAuditPermission} {unpackFlags} />
|
||||
|
||||
<Paginator meta={data.meta} />
|
||||
</ColumnView>
|
@ -10,12 +10,16 @@
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import AdminAuditCard from '$lib/components/admin/AdminAuditCard.svelte';
|
||||
import FormActions from '$lib/components/form/FormActions.svelte';
|
||||
import { hasPrivileges } from '$lib/utils';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const hasIpPermission = $derived(hasPrivileges(data.user.privileges, ['admin:ip']));
|
||||
const hasUserPermission = $derived(hasPrivileges(data.user.privileges, ['admin:user']));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@ -81,7 +85,7 @@
|
||||
<div class="audit-list">
|
||||
<Paginator meta={data.meta} />
|
||||
{#each data.list as audit}
|
||||
<AdminAuditCard {audit} />
|
||||
<AdminAuditCard {audit} ipLink={hasIpPermission} userLink={hasUserPermission} />
|
||||
{/each}
|
||||
<Paginator meta={data.meta} />
|
||||
</div>
|
||||
|
@ -4,6 +4,7 @@ import { Audit, AuditAction } from '$lib/server/audit';
|
||||
import { Changesets } from '$lib/server/changesets.js';
|
||||
import { CryptoUtils } from '$lib/server/crypto-utils.js';
|
||||
import type { OAuth2Client, User } from '$lib/server/drizzle';
|
||||
import { IPAddresses, IPFlag } from '$lib/server/ip';
|
||||
import {
|
||||
OAuth2ClientURLType,
|
||||
OAuth2Clients,
|
||||
@ -359,6 +360,11 @@ export const actions = {
|
||||
avatar: async ({ request, locals, params: { uuid }, getClientAddress }) => {
|
||||
const { currentUser, details } = await getActionData(locals, uuid);
|
||||
|
||||
// IP banned from uploading files
|
||||
if (await IPAddresses.isIpFlagged(getClientAddress(), IPFlag.RESTRICT_UPLOAD)) {
|
||||
throw error(403);
|
||||
}
|
||||
|
||||
const formData = Object.fromEntries(await request.formData());
|
||||
if (!(formData.file as File)?.name || (formData.file as File).name === 'undefined') {
|
||||
return fail(400, {
|
||||
|
@ -1,4 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import ActionButton from '$lib/components/ActionButton.svelte';
|
||||
import { onNavigate } from '$app/navigation';
|
||||
import { popupFormErrors } from '$lib/form-errors';
|
||||
import { displayMessage, clearMessages } from '$lib/stores/messages.store';
|
||||
import { get } from 'svelte/store';
|
||||
import { hasPrivileges } from '$lib/utils';
|
||||
import AdminPrivilegesSelect from '$lib/components/admin/AdminPrivilegesSelect.svelte';
|
||||
import SplitView from '$lib/components/container/SplitView.svelte';
|
||||
import FormWrapper from '$lib/components/form/FormWrapper.svelte';
|
||||
import FormSection from '$lib/components/form/FormSection.svelte';
|
||||
@ -6,15 +16,6 @@
|
||||
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 { env } from '$env/dynamic/public';
|
||||
import ActionButton from '$lib/components/ActionButton.svelte';
|
||||
import { onNavigate } from '$app/navigation';
|
||||
import { popupFormErrors } from '$lib/form-errors';
|
||||
import { displayMessage, clearMessages } from '$lib/stores/messages.store';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
@ -23,6 +24,9 @@
|
||||
|
||||
let { data, form }: Props = $props();
|
||||
|
||||
const hasIpPrivileges = $derived(hasPrivileges(data.user.privileges, ['admin:ip']));
|
||||
const hasAuditPrivileges = $derived(hasPrivileges(data.user.privileges, ['admin:audit']));
|
||||
|
||||
$effect(() => popupFormErrors(form, 'admin.users.errors'));
|
||||
$effect(() => {
|
||||
if (form?.errors && !form.errors.length) {
|
||||
@ -97,16 +101,39 @@
|
||||
{/if}
|
||||
|
||||
<h3>{$t('admin.users.actions')}</h3>
|
||||
<ul>
|
||||
{#if hasIpPrivileges}
|
||||
<li>
|
||||
<a href="/ssoadmin/addresses?user={data.details.uuid}">{$t('admin.ip.title')}</a>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{#if hasAuditPrivileges}
|
||||
<li>
|
||||
<a href="/ssoadmin/audit?user={data.details.uuid}">{$t('admin.audit.title')}</a>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{#if data.details.activated}
|
||||
<ActionButton action="?/email&type=password">{$t('admin.users.passwordEmail')}</ActionButton>
|
||||
{:else}
|
||||
<ActionButton action="?/email&type=activate">{$t('admin.users.activationEmail')}</ActionButton
|
||||
<li>
|
||||
<ActionButton action="?/email&type=password"
|
||||
>{$t('admin.users.passwordEmail')}</ActionButton
|
||||
>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<ActionButton action="?/email&type=activate"
|
||||
>{$t('admin.users.activationEmail')}</ActionButton
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<form action="?/deleteInfo" method="POST">
|
||||
<Button type="submit" variant="link">{$t('admin.users.deleteInfo')}</Button>
|
||||
- <span>{$t('admin.users.deleteInfoHint')}</span>
|
||||
</form>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</ColumnView>
|
||||
</SplitView>
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user