a04ecf7a19
- Voice messages: MediaRecorder → encrypted attachment, custom waveform player via OfflineAudioContext, 60s limit + live mic-level meter - Offline message queue: localStorage outbox, exponential backoff retries, optimistic pending bubble with retry/discard - Delivery indicator: message_deliveries table + RLS (reciprocal receipts), ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack) - Per-participant volume slider in calls via right-click tile menu, persisted to localStorage, applied to attached audio elements - Group call scaling: grid up to 12 tiles with pagination, active-speaker auto-promotion in fullscreen - Push notifications scaffolding: service worker, VAPID subscription registration, notify-push edge function skeleton - Backup recovery code: 24-char base32 code (~120 bits entropy) as alternative decrypt path, restore UI with mode toggle - Admin panel: conversations list, audit log (admin_audit_log table + admin_log_action RPC), audit entry on user flag toggle - Search v2: sender filter, attachment-only toggle, date range - Reactions pop animation (scale 0.4→1.15→1 on count change) - Message list windowing (150 default, expand via IntersectionObserver) - Stub cleanup: removed dead ScreenshareStub from CallParticipantTile Fixes: - Focus-triggered flicker: dropped window.focus listeners in three spots, throttled visibilitychange/online wake-refreshes to 30s, keep existing data visible during background re-syncs (no more spinner on every click) - Voice attachment audio element collapsed to 0px on peer side — now forces 280px min-width on bubble Migrations (push required): 20260421000001_message_deliveries.sql 20260421000002_admin_audit_log.sql Server TODO: VAPID keys + notify-push edge function deploy
328 lines
9.4 KiB
TypeScript
328 lines
9.4 KiB
TypeScript
// Admin-only helpers. Every call is RLS-gated server-side via the
|
|
// `current_user_is_admin()` helper + admin-specific policies. Non-admins
|
|
// trying to call these still get clean PostgREST 403/empty-result responses.
|
|
|
|
import type { AppSupabaseClient } from '../supabase/client.js';
|
|
|
|
// --- Admin settings -------------------------------------------------------
|
|
|
|
export interface AdminSetting {
|
|
key: string;
|
|
value: unknown;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export async function listAdminSettings(client: AppSupabaseClient): Promise<AdminSetting[]> {
|
|
const { data, error } = await client
|
|
.from('admin_settings')
|
|
.select('key, value, updated_at')
|
|
.order('key', { ascending: true });
|
|
if (error) throw error;
|
|
return (data ?? []).map((r) => ({ key: r.key, value: r.value, updatedAt: r.updated_at }));
|
|
}
|
|
|
|
export async function updateAdminSetting(
|
|
client: AppSupabaseClient,
|
|
key: string,
|
|
value: unknown,
|
|
): Promise<void> {
|
|
const { error } = await client
|
|
.from('admin_settings')
|
|
.upsert(
|
|
{
|
|
key,
|
|
value: value as never,
|
|
updated_at: new Date().toISOString(),
|
|
} as never,
|
|
{ onConflict: 'key' },
|
|
);
|
|
if (error) throw error;
|
|
}
|
|
|
|
// --- Invite codes ---------------------------------------------------------
|
|
|
|
export interface InviteRecord {
|
|
code: string;
|
|
createdBy: string | null;
|
|
usesLimit: number | null;
|
|
usesCount: number;
|
|
expiresAt: string | null;
|
|
disabled: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
function mapInvite(row: {
|
|
code: string;
|
|
created_by: string | null;
|
|
uses_limit: number | null;
|
|
uses_count: number;
|
|
expires_at: string | null;
|
|
disabled: boolean;
|
|
created_at: string;
|
|
}): InviteRecord {
|
|
return {
|
|
code: row.code,
|
|
createdBy: row.created_by,
|
|
usesLimit: row.uses_limit,
|
|
usesCount: row.uses_count,
|
|
expiresAt: row.expires_at,
|
|
disabled: row.disabled,
|
|
createdAt: row.created_at,
|
|
};
|
|
}
|
|
|
|
export async function listInvites(client: AppSupabaseClient): Promise<InviteRecord[]> {
|
|
const { data, error } = await client
|
|
.from('invites')
|
|
.select('code, created_by, uses_limit, uses_count, expires_at, disabled, created_at')
|
|
.order('created_at', { ascending: false });
|
|
if (error) throw error;
|
|
return (data ?? []).map(mapInvite);
|
|
}
|
|
|
|
export interface CreateInviteParams {
|
|
code?: string; // omit to auto-generate
|
|
usesLimit?: number | null;
|
|
expiresAt?: string | null; // ISO
|
|
}
|
|
|
|
const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
function randomCode(length = 10): string {
|
|
const buf =
|
|
typeof crypto !== 'undefined' && 'getRandomValues' in crypto
|
|
? crypto.getRandomValues(new Uint8Array(length))
|
|
: null;
|
|
let out = '';
|
|
for (let i = 0; i < length; i++) {
|
|
const v = buf ? buf[i]! : Math.floor(Math.random() * 256);
|
|
const ch = INVITE_ALPHABET[v % INVITE_ALPHABET.length];
|
|
if (ch !== undefined) out += ch;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export async function createInvite(
|
|
client: AppSupabaseClient,
|
|
params: CreateInviteParams = {},
|
|
): Promise<InviteRecord> {
|
|
const { data: session, error: aErr } = await client.auth.getUser();
|
|
if (aErr) throw aErr;
|
|
if (!session.user) throw new Error('not authenticated');
|
|
|
|
const code = params.code?.trim() || randomCode(10);
|
|
const { data, error } = await client
|
|
.from('invites')
|
|
.insert({
|
|
code,
|
|
created_by: session.user.id,
|
|
uses_limit: params.usesLimit ?? null,
|
|
expires_at: params.expiresAt ?? null,
|
|
disabled: false,
|
|
} as never)
|
|
.select('code, created_by, uses_limit, uses_count, expires_at, disabled, created_at')
|
|
.single();
|
|
if (error) throw error;
|
|
return mapInvite(data as never);
|
|
}
|
|
|
|
export async function setInviteDisabled(
|
|
client: AppSupabaseClient,
|
|
code: string,
|
|
disabled: boolean,
|
|
): Promise<void> {
|
|
const { error } = await client
|
|
.from('invites')
|
|
.update({ disabled } as never)
|
|
.eq('code', code);
|
|
if (error) throw error;
|
|
}
|
|
|
|
export async function deleteInvite(client: AppSupabaseClient, code: string): Promise<void> {
|
|
const { error } = await client.from('invites').delete().eq('code', code);
|
|
if (error) throw error;
|
|
}
|
|
|
|
// --- User admin ops -------------------------------------------------------
|
|
|
|
export interface AdminProfileRow {
|
|
userId: string;
|
|
username: string;
|
|
displayName: string;
|
|
isAdmin: boolean;
|
|
banned: boolean;
|
|
blockedFromInviting: boolean;
|
|
createdAt: string;
|
|
}
|
|
|
|
export async function listAllProfiles(client: AppSupabaseClient): Promise<AdminProfileRow[]> {
|
|
const { data, error } = await client
|
|
.from('profiles')
|
|
.select('user_id, username, display_name, is_admin, banned, blocked_from_inviting, created_at')
|
|
.order('created_at', { ascending: false });
|
|
if (error) throw error;
|
|
return (data ?? []).map((r) => ({
|
|
userId: r.user_id,
|
|
username: r.username,
|
|
displayName: r.display_name,
|
|
isAdmin: r.is_admin,
|
|
banned: r.banned,
|
|
blockedFromInviting: r.blocked_from_inviting,
|
|
createdAt: r.created_at,
|
|
}));
|
|
}
|
|
|
|
export type AdminProfileFlag = 'is_admin' | 'banned' | 'blocked_from_inviting';
|
|
|
|
export async function setUserFlag(
|
|
client: AppSupabaseClient,
|
|
userId: string,
|
|
flag: AdminProfileFlag,
|
|
value: boolean,
|
|
): Promise<void> {
|
|
const patch: Record<string, boolean> = {};
|
|
patch[flag] = value;
|
|
const { error } = await client
|
|
.from('profiles')
|
|
.update(patch as never)
|
|
.eq('user_id', userId);
|
|
if (error) throw error;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Conversations (admin view — reads all regardless of membership)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface AdminConversationRow {
|
|
id: string;
|
|
type: 'dm' | 'group';
|
|
name: string | null;
|
|
memberCount: number;
|
|
lastMessageAt: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export async function listAllConversations(
|
|
client: AppSupabaseClient,
|
|
): Promise<AdminConversationRow[]> {
|
|
const { data, error } = await client
|
|
.from('conversations')
|
|
.select('id, type, name, created_at')
|
|
.order('created_at', { ascending: false });
|
|
if (error) throw error;
|
|
const conversations = data ?? [];
|
|
if (conversations.length === 0) return [];
|
|
const ids = conversations.map((c) => c.id);
|
|
const { data: memberRows, error: mErr } = await client
|
|
.from('conversation_members')
|
|
.select('conversation_id')
|
|
.in('conversation_id', ids);
|
|
if (mErr) throw mErr;
|
|
const counts = new Map<string, number>();
|
|
for (const row of memberRows ?? []) {
|
|
counts.set(row.conversation_id, (counts.get(row.conversation_id) ?? 0) + 1);
|
|
}
|
|
// Approximate "last message at" by peeking at latest message.created_at per
|
|
// conversation. Cheap since conversation count stays small (<20 users,
|
|
// <few hundred conversations).
|
|
const { data: lastMsg, error: lErr } = await client
|
|
.from('messages')
|
|
.select('conversation_id, created_at')
|
|
.in('conversation_id', ids)
|
|
.order('created_at', { ascending: false })
|
|
.limit(5000);
|
|
if (lErr) throw lErr;
|
|
const lastAt = new Map<string, string>();
|
|
for (const row of lastMsg ?? []) {
|
|
if (!lastAt.has(row.conversation_id)) {
|
|
lastAt.set(row.conversation_id, row.created_at);
|
|
}
|
|
}
|
|
return conversations.map((c) => ({
|
|
id: c.id,
|
|
type: c.type as 'dm' | 'group',
|
|
name: c.name,
|
|
memberCount: counts.get(c.id) ?? 0,
|
|
lastMessageAt: lastAt.get(c.id) ?? null,
|
|
createdAt: c.created_at,
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Audit log
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface AdminAuditEntry {
|
|
id: string;
|
|
actorId: string | null;
|
|
action: string;
|
|
targetType: string | null;
|
|
targetId: string | null;
|
|
metadata: Record<string, unknown>;
|
|
createdAt: string;
|
|
}
|
|
|
|
export async function listAuditLog(
|
|
client: AppSupabaseClient,
|
|
limit = 100,
|
|
): Promise<AdminAuditEntry[]> {
|
|
const { data, error } = await (client as unknown as {
|
|
from: (t: string) => {
|
|
select: (cols: string) => {
|
|
order: (
|
|
col: string,
|
|
opts: { ascending: boolean },
|
|
) => {
|
|
limit: (n: number) => Promise<{
|
|
data:
|
|
| {
|
|
id: string;
|
|
actor_id: string | null;
|
|
action: string;
|
|
target_type: string | null;
|
|
target_id: string | null;
|
|
metadata: Record<string, unknown>;
|
|
created_at: string;
|
|
}[]
|
|
| null;
|
|
error: Error | null;
|
|
}>;
|
|
};
|
|
};
|
|
};
|
|
})
|
|
.from('admin_audit_log')
|
|
.select('id, actor_id, action, target_type, target_id, metadata, created_at')
|
|
.order('created_at', { ascending: false })
|
|
.limit(limit);
|
|
if (error) throw error;
|
|
return (data ?? []).map((r) => ({
|
|
id: r.id,
|
|
actorId: r.actor_id,
|
|
action: r.action,
|
|
targetType: r.target_type,
|
|
targetId: r.target_id,
|
|
metadata: r.metadata ?? {},
|
|
createdAt: r.created_at,
|
|
}));
|
|
}
|
|
|
|
export async function logAdminAction(
|
|
client: AppSupabaseClient,
|
|
action: string,
|
|
target?: { type?: string; id?: string },
|
|
metadata?: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const { error } = await (client as unknown as {
|
|
rpc: (
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
) => Promise<{ error: Error | null }>;
|
|
}).rpc('admin_log_action', {
|
|
p_action: action,
|
|
p_target_type: target?.type ?? null,
|
|
p_target_id: target?.id ?? null,
|
|
p_metadata: metadata ?? {},
|
|
});
|
|
if (error) throw error;
|
|
}
|