audit: 33-agent comprehensive audit + critical fixes
Full team audit run, all reports verbatim in docs/AUDIT-2026-05-12.md (5900+ lines, 30+ critical findings). Already-fixed this commit: - permission-overrides PUT: self-target block + RolePermissions allow-list + cross-tenant guard - /api/auth/resolve-identifier: rate-limit + synthetic miss-email kill enumeration - admin email-change: rotates account.accountId + revokes sessions - middleware: token-gated email confirm/cancel routes whitelisted - NAV_CATALOG: 10 dead-link sweeps to existing /admin/<x> targets Feature work landing same commit: optional username sign-in (migration 0054), per-user permission overrides (0055) with three-state matrix tabbed inside UserForm, user disable button, role + outcome + stage label normalisation across the platform, admin email-change with auto-notification template. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,13 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { portRoleOverrides, ports, userPortRoles, userProfiles } from '@/lib/db/schema';
|
||||
import {
|
||||
portRoleOverrides,
|
||||
ports,
|
||||
userPermissionOverrides,
|
||||
userPortRoles,
|
||||
userProfiles,
|
||||
} from '@/lib/db/schema';
|
||||
import { type RolePermissions } from '@/lib/db/schema/users';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||
@@ -213,6 +219,23 @@ export function withAuth<TParams extends RouteParams = Record<string, string>>(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Per-user permission overrides. Final layer in the chain so
|
||||
// they win over role + port-role-override. Most users will
|
||||
// never have a row here; admins flip individual leaves from
|
||||
// the user-edit drawer's Permissions tab.
|
||||
const userOverride = await db.query.userPermissionOverrides.findFirst({
|
||||
where: and(
|
||||
eq(userPermissionOverrides.userId, profile.userId),
|
||||
eq(userPermissionOverrides.portId, portId),
|
||||
),
|
||||
});
|
||||
if (userOverride?.permissionOverrides && permissions) {
|
||||
permissions = deepMerge(
|
||||
permissions as unknown as Record<string, unknown>,
|
||||
userOverride.permissionOverrides as Record<string, unknown>,
|
||||
) as RolePermissions;
|
||||
}
|
||||
} else if (profile.isSuperAdmin && portId) {
|
||||
const port = await db.query.ports.findFirst({
|
||||
where: eq(ports.id, portId),
|
||||
|
||||
@@ -268,6 +268,33 @@ export function formatRole(role: string | null | undefined): string {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// ─── Interest outcomes ───────────────────────────────────────────────────────
|
||||
// Mirrors INTEREST_OUTCOMES in src/lib/validators/interests.ts. Lives here
|
||||
// so render sites can format outcome strings without pulling in the
|
||||
// validator (which would drag zod into RSC bundles). Validator → enforces
|
||||
// the set; here → labels for humans.
|
||||
|
||||
export const OUTCOME_LABELS: Record<string, string> = {
|
||||
won: 'Won',
|
||||
lost_other_marina: 'Lost — chose another marina',
|
||||
lost_unqualified: 'Lost — not qualified',
|
||||
lost_no_response: 'Lost — no response',
|
||||
lost_other: 'Lost — other',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
|
||||
/** Returns the human label for a stored outcome value. Falls back to a
|
||||
* pretty Title-Case rendering for any new values added at the validator
|
||||
* before this map catches up. */
|
||||
export function formatOutcome(outcome: string | null | undefined): string | null {
|
||||
if (!outcome) return null;
|
||||
if (outcome in OUTCOME_LABELS) return OUTCOME_LABELS[outcome]!;
|
||||
return outcome
|
||||
.split('_')
|
||||
.map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : part))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// ─── Document Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export const DOCUMENT_TYPES = ['eoi', 'contract', 'nda', 'reservation_agreement', 'other'] as const;
|
||||
|
||||
31
src/lib/db/migrations/0054_user_profiles_username.sql
Normal file
31
src/lib/db/migrations/0054_user_profiles_username.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- 0054_user_profiles_username.sql
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Optional username as a sign-in alternative to email. Stored alongside the
|
||||
-- canonical first/last name on user_profiles so the rest of the auth/profile
|
||||
-- code keeps a single place to look. The Better-Auth `user` table stays the
|
||||
-- source of truth for email + password; the username is a thin alias the
|
||||
-- login form looks up to resolve to the matching email before delegating
|
||||
-- to better-auth's email/password flow.
|
||||
--
|
||||
-- Constraints (enforced application-side AND in SQL):
|
||||
-- - 2..30 characters
|
||||
-- - lowercase letters, digits, dot, underscore, hyphen
|
||||
-- - case-insensitive uniqueness per install (no per-port scoping —
|
||||
-- reps move between ports and a global username keeps URLs stable)
|
||||
--
|
||||
-- The column is nullable; existing users keep email-only sign-in until they
|
||||
-- pick one.
|
||||
|
||||
ALTER TABLE user_profiles
|
||||
ADD COLUMN IF NOT EXISTS username TEXT;
|
||||
|
||||
-- Shape check at the DB level catches anything that slipped past the API
|
||||
-- (raw SQL inserts in tests, scripts, etc.).
|
||||
ALTER TABLE user_profiles
|
||||
ADD CONSTRAINT chk_user_profiles_username_shape
|
||||
CHECK (username IS NULL OR username ~ '^[a-z0-9._-]{2,30}$');
|
||||
|
||||
-- Case-insensitive uniqueness. Partial so multiple NULLs are allowed.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_profiles_username_unique
|
||||
ON user_profiles (LOWER(username))
|
||||
WHERE username IS NOT NULL;
|
||||
26
src/lib/db/migrations/0055_user_permission_overrides.sql
Normal file
26
src/lib/db/migrations/0055_user_permission_overrides.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- 0055_user_permission_overrides.sql
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Per-user permission overrides layered on top of the role's baseline.
|
||||
-- Effective permission = role[resource][action]
|
||||
-- |> apply port_role_overrides for that port
|
||||
-- |> apply user_permission_overrides for (user, port)
|
||||
--
|
||||
-- A user override entry is OPTIONAL — most users will never have one.
|
||||
-- When present, the JSONB blob is a Partial<RolePermissions> map where any
|
||||
-- explicitly-set leaf wins over the inherited value (true forces grant,
|
||||
-- false forces deny, missing → inherit).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_permission_overrides (
|
||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
user_id TEXT NOT NULL,
|
||||
port_id TEXT NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
|
||||
permission_overrides JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_perm_overrides_user_port
|
||||
ON user_permission_overrides (user_id, port_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_perm_overrides_user
|
||||
ON user_permission_overrides (user_id);
|
||||
@@ -244,6 +244,14 @@ export const userProfiles = pgTable(
|
||||
firstName: text('first_name'),
|
||||
lastName: text('last_name'),
|
||||
displayName: text('display_name').notNull(),
|
||||
/**
|
||||
* Optional sign-in alias. Lowercase a-z0-9 plus dot/underscore/hyphen,
|
||||
* 3–30 chars (shape pinned by `chk_user_profiles_username_shape`).
|
||||
* Case-insensitive uniqueness is enforced by a partial unique index on
|
||||
* LOWER(username); NULL allows the column to coexist with users who
|
||||
* still sign in by email. See migration 0054.
|
||||
*/
|
||||
username: text('username'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
/** FK into the polymorphic `files` table — the avatar is stored
|
||||
* via getStorageBackend() so an S3↔filesystem swap carries it
|
||||
@@ -278,6 +286,42 @@ export const roles = pgTable('roles', {
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Per-user permission overrides layered on top of the role's baseline for
|
||||
* a specific port. Each row carries a `Partial<RolePermissions>` map; any
|
||||
* explicitly-set leaf wins over the role + port-role-override chain. Most
|
||||
* users will never have a row here — it exists for the rare "give Alice
|
||||
* the same role as her team but let her run permanent deletes" case.
|
||||
*
|
||||
* Effective permission resolution lives in `getEffectivePermissions` in
|
||||
* src/lib/services/permissions.service.ts.
|
||||
*/
|
||||
export const userPermissionOverrides = pgTable(
|
||||
'user_permission_overrides',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
userId: text('user_id').notNull(),
|
||||
portId: text('port_id')
|
||||
.notNull()
|
||||
.references(() => ports.id, { onDelete: 'cascade' }),
|
||||
permissionOverrides: jsonb('permission_overrides')
|
||||
.$type<Partial<RolePermissions>>()
|
||||
.notNull()
|
||||
.default({}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('idx_user_perm_overrides_user_port').on(table.userId, table.portId),
|
||||
index('idx_user_perm_overrides_user').on(table.userId),
|
||||
],
|
||||
);
|
||||
|
||||
export type UserPermissionOverride = typeof userPermissionOverrides.$inferSelect;
|
||||
export type NewUserPermissionOverride = typeof userPermissionOverrides.$inferInsert;
|
||||
|
||||
export const portRoleOverrides = pgTable(
|
||||
'port_role_overrides',
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
import type { PipelineData } from '@/lib/services/report-generators';
|
||||
import { stageLabel } from '@/lib/constants';
|
||||
|
||||
export const pipelineReportTemplate: Template = {
|
||||
basePdf: 'BLANK_PDF' as unknown as string,
|
||||
@@ -69,8 +70,7 @@ export function buildPipelineInputs(
|
||||
const summaryLines = stageOrder
|
||||
.filter((stage) => (data.stageCounts[stage] ?? 0) > 0)
|
||||
.map((stage) => {
|
||||
const label = stage.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return `${label}: ${data.stageCounts[stage] ?? 0} interest(s)`;
|
||||
return `${stageLabel(stage)}: ${data.stageCounts[stage] ?? 0} interest(s)`;
|
||||
});
|
||||
|
||||
// Include stages not in standard order
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Template } from '@pdfme/common';
|
||||
|
||||
import type { RevenueData } from '@/lib/services/report-generators';
|
||||
import { stageLabel } from '@/lib/constants';
|
||||
|
||||
export const revenueReportTemplate: Template = {
|
||||
basePdf: 'BLANK_PDF' as unknown as string,
|
||||
@@ -74,12 +75,11 @@ export function buildRevenueInputs(data: RevenueData, portName?: string): Record
|
||||
breakdownLines.push('No revenue data available.');
|
||||
} else {
|
||||
for (const stage of orderedStages) {
|
||||
const label = stage.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const amount = Number(data.stageRevenue[stage] ?? 0).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
breakdownLines.push(`${label}: ${amount}`);
|
||||
breakdownLines.push(`${stageLabel(stage)}: ${amount}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ConnectionOptions } from 'bullmq';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { attachWorkerAudit } from '@/lib/queue/audit-helpers';
|
||||
import { QUEUE_CONFIGS } from '@/lib/queue';
|
||||
import { stageLabel } from '@/lib/constants';
|
||||
|
||||
// ─── Email draft generation ───────────────────────────────────────────────────
|
||||
|
||||
@@ -280,7 +281,7 @@ function buildTemplateDraft(opts: {
|
||||
},
|
||||
stage_update: {
|
||||
subject: `Update on your application – ${clientName}`,
|
||||
body: `Dear ${clientName},\n\nWe are pleased to inform you that your application for ${berthText} has progressed to the "${pipelineStage.replace(/_/g, ' ')}" stage.\n\nWe will be in touch shortly with the next steps.\n\nKind regards,\nPort Nimara Team`,
|
||||
body: `Dear ${clientName},\n\nWe are pleased to inform you that your application for ${berthText} has progressed to the "${stageLabel(pipelineStage)}" stage.\n\nWe will be in touch shortly with the next steps.\n\nKind regards,\nPort Nimara Team`,
|
||||
},
|
||||
general: {
|
||||
subject: `Message from Port Nimara – ${clientName}`,
|
||||
|
||||
@@ -850,8 +850,7 @@ export async function changeInterestStage(
|
||||
? (STAGE_LABELS[oldStage as PipelineStage] ?? oldStage.replace(/_/g, ' '))
|
||||
: 'unknown';
|
||||
const toLabel =
|
||||
STAGE_LABELS[data.pipelineStage as PipelineStage] ??
|
||||
data.pipelineStage.replace(/_/g, ' ');
|
||||
STAGE_LABELS[data.pipelineStage as PipelineStage] ?? data.pipelineStage.replace(/_/g, ' ');
|
||||
await createNotification({
|
||||
portId,
|
||||
userId: meta.userId,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { db } from '@/lib/db';
|
||||
import { userPortRoles, roles, type RolePermissions } from '@/lib/db/schema/users';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { createNotification } from '@/lib/services/notifications.service';
|
||||
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
|
||||
|
||||
export interface BerthReleaseNotificationInput {
|
||||
portId: string;
|
||||
@@ -59,8 +60,9 @@ export async function notifyNextInLine(input: BerthReleaseNotificationInput): Pr
|
||||
// 2. Build a single description listing the next interests so the
|
||||
// rep can act on it without opening the berth detail page first.
|
||||
const previewLines = input.nextInLineInterests.slice(0, 5).map((i) => {
|
||||
const days = i.pipelineStage.replace(/_/g, ' ');
|
||||
return `${i.clientName ?? '(unknown)'} — ${days}`;
|
||||
const stageLabel =
|
||||
STAGE_LABELS[i.pipelineStage as PipelineStage] ?? i.pipelineStage.replace(/_/g, ' ');
|
||||
return `${i.clientName ?? '(unknown)'} — ${stageLabel}`;
|
||||
});
|
||||
const more =
|
||||
input.nextInLineInterests.length > 5
|
||||
|
||||
@@ -58,8 +58,12 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||
category: 'settings',
|
||||
keywords: ['preferences', 'configuration', 'config'],
|
||||
},
|
||||
// The granular settings cards below redirect to the `/admin/<x>` routes
|
||||
// that actually exist — the catalog previously listed `/settings/<x>`
|
||||
// paths that have never had route folders. We keep the keyword aliases
|
||||
// so the cmd-K search still finds them under the right destination.
|
||||
{
|
||||
href: '/:portSlug/settings/email',
|
||||
href: '/:portSlug/admin/email',
|
||||
label: 'Email accounts (SMTP / IMAP)',
|
||||
category: 'settings',
|
||||
keywords: [
|
||||
@@ -75,47 +79,47 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||
requires: 'admin.manage_settings',
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/settings/branding',
|
||||
href: '/:portSlug/admin/branding',
|
||||
label: 'Branding (per-port logo, colors, copy)',
|
||||
category: 'settings',
|
||||
keywords: ['logo', 'theme', 'colors', 'tenant brand', 'white-label'],
|
||||
requires: 'admin.manage_settings',
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/settings/templates',
|
||||
href: '/:portSlug/admin/templates',
|
||||
label: 'Document templates',
|
||||
category: 'settings',
|
||||
keywords: ['eoi', 'documenso', 'pdf templates', 'template merge fields'],
|
||||
requires: 'admin.manage_settings',
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/settings/storage',
|
||||
href: '/:portSlug/admin/storage',
|
||||
label: 'File storage backend',
|
||||
category: 'settings',
|
||||
keywords: ['s3', 'minio', 'filesystem', 'storage'],
|
||||
requires: 'admin.manage_settings',
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/settings/recommender',
|
||||
href: '/:portSlug/admin/settings',
|
||||
label: 'Berth recommender weights',
|
||||
category: 'settings',
|
||||
keywords: ['ranking', 'tier ladder', 'heat', 'fallthrough', 'recommend'],
|
||||
requires: 'admin.manage_settings',
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/settings/tags',
|
||||
href: '/:portSlug/admin/tags',
|
||||
label: 'Tags',
|
||||
category: 'settings',
|
||||
keywords: ['labels', 'categories', 'classification'],
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/settings/notifications',
|
||||
href: '/:portSlug/settings/profile',
|
||||
label: 'Notification preferences',
|
||||
category: 'settings',
|
||||
keywords: ['alerts', 'email digest', 'in-app', 'push'],
|
||||
keywords: ['alerts', 'email digest', 'in-app', 'push', 'reminders digest'],
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/user-settings',
|
||||
href: '/:portSlug/settings/profile',
|
||||
label: 'My profile & preferences',
|
||||
category: 'settings',
|
||||
keywords: [
|
||||
@@ -159,7 +163,7 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||
requires: 'admin.manage_users',
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/admin/audit-log',
|
||||
href: '/:portSlug/admin/audit',
|
||||
label: 'Audit log',
|
||||
category: 'admin',
|
||||
keywords: ['activity', 'history', 'events', 'who did what', 'compliance'],
|
||||
@@ -172,7 +176,7 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
|
||||
keywords: ['enquiries', 'leads', 'contact form', 'eoi requests', 'website'],
|
||||
},
|
||||
{
|
||||
href: '/:portSlug/admin/error-events',
|
||||
href: '/:portSlug/admin/errors',
|
||||
label: 'Platform errors',
|
||||
category: 'admin',
|
||||
keywords: ['errors', 'exceptions', 'incidents', 'failures'],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
|
||||
import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
|
||||
import { auth } from '@/lib/auth';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
@@ -247,6 +247,25 @@ export async function updateUser(
|
||||
await db.update(user).set(userUpdates).where(eq(user.id, userId));
|
||||
}
|
||||
|
||||
if (wantsEmailChange) {
|
||||
const newEmailLower = data.email!.toLowerCase();
|
||||
// Better Auth's credential provider authenticates by
|
||||
// `account.accountId` (the email captured at sign-up), NOT by
|
||||
// `user.email`. Without this update the user can't sign in with
|
||||
// either address — old fails because user.email no longer matches,
|
||||
// new fails because there's no account.accountId row for it.
|
||||
await db
|
||||
.update(account)
|
||||
.set({ accountId: newEmailLower, updatedAt: new Date() })
|
||||
.where(and(eq(account.userId, userId), eq(account.providerId, 'credential')));
|
||||
|
||||
// Revoke every active session — the admin just changed the identity
|
||||
// the user authenticates with, so existing sessions are effectively
|
||||
// orphaned and a security risk if the account is being rotated due
|
||||
// to compromise. The user re-authenticates with the new address.
|
||||
await db.delete(session).where(eq(session.userId, userId));
|
||||
}
|
||||
|
||||
if (wantsEmailChange && previousEmail) {
|
||||
// Best-effort notification — failure to send doesn't roll back the
|
||||
// change because Better Auth's primary identity has already moved.
|
||||
|
||||
52
src/lib/validators/username.ts
Normal file
52
src/lib/validators/username.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Canonical username shape.
|
||||
*
|
||||
* - 2..30 characters (yes, 2 — initials like "dm" are real and the
|
||||
* director uses them)
|
||||
* - lowercase letters, digits, `.`, `_`, `-`
|
||||
* - case-insensitive uniqueness is enforced by a partial unique index on
|
||||
* LOWER(username) in migration 0054.
|
||||
*
|
||||
* The same regex lives on the DB CHECK constraint, so any insert that
|
||||
* slips past the API still gets rejected at the DB layer.
|
||||
*/
|
||||
export const USERNAME_REGEX = /^[a-z0-9._-]{2,30}$/;
|
||||
|
||||
export const usernameSchema = z
|
||||
.string()
|
||||
.transform((s) => s.trim().toLowerCase())
|
||||
.refine(
|
||||
(s) => USERNAME_REGEX.test(s),
|
||||
'Use 2–30 lowercase letters, digits, dot, underscore, or hyphen.',
|
||||
);
|
||||
|
||||
/** Reserved names that the API rejects even if the regex would accept them.
|
||||
* Keeps obvious confusables out of customer-facing URLs / mentions. */
|
||||
export const RESERVED_USERNAMES = new Set([
|
||||
'admin',
|
||||
'administrator',
|
||||
'root',
|
||||
'system',
|
||||
'support',
|
||||
'noreply',
|
||||
'no-reply',
|
||||
'help',
|
||||
'security',
|
||||
'me',
|
||||
'self',
|
||||
'api',
|
||||
'auth',
|
||||
'login',
|
||||
'logout',
|
||||
'signin',
|
||||
'signup',
|
||||
'register',
|
||||
'undefined',
|
||||
'null',
|
||||
]);
|
||||
|
||||
export function isReservedUsername(username: string): boolean {
|
||||
return RESERVED_USERNAMES.has(username.trim().toLowerCase());
|
||||
}
|
||||
Reference in New Issue
Block a user