Compare commits

..
3 Commits
Author SHA1 Message Date
shuaiplus b093c01fd7 chore: update version to 1.7.4 in package.json, package-lock.json, and app-version.ts 2026-07-12 22:13:33 +08:00
shuaiplus fa611dc843 fix: require master password for admin and wipe-device actions
Gate invite management, user ban/delete, and delete-all-devices behind
masterPasswordHash verification, matching backup step-up auth. The web UI
prompts for the master password in the shared confirm dialog.
2026-07-12 20:43:27 +08:00
shuaiplus 3c581d1fb1 fix: block IPv6 loopback in backup destination URL checks
Expand compressed IPv6 hostnames before the private-address allowlist so
forms like ::1 cannot bypass SSRF protection for WebDAV/S3 backup endpoints.
Also reject IPv4-mapped addresses written as ::ffff:hex:hex.
2026-07-12 20:21:45 +08:00
13 changed files with 317 additions and 73 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.3", "version": "1.7.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.3", "version": "1.7.4",
"license": "LGPL-3.0", "license": "LGPL-3.0",
"dependencies": { "dependencies": {
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.3", "version": "1.7.4",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers", "description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus", "author": "shuaiplus",
"license": "LGPL-3.0", "license": "LGPL-3.0",
@@ -0,0 +1,38 @@
import { normalizeBackupEndpointUrl } from '../src/services/backup-config.ts';
import fs from 'node:fs';
const scratch = process.env.SCRATCH || '.';
const cases = [
'http://127.0.0.1',
'http://169.254.169.254',
'http://[::1]',
'http://[0:0:0:0:0:0:0:1]',
'http://[::2]',
'http://[::]',
'http://[fe80::1]',
'http://[fc00::1]',
'https://example.com',
];
const out = [];
for (const url of cases) {
try {
const normalized = normalizeBackupEndpointUrl(url, 'WebDAV server URL');
out.push({ url, allowed: true, normalized });
} catch (e) {
out.push({ url, allowed: false, error: e instanceof Error ? e.message : String(e) });
}
}
const path = `${scratch}/poc-normalizeBackupEndpointUrl.json`;
fs.writeFileSync(path, JSON.stringify(out, null, 2));
console.log(JSON.stringify(out, null, 2));
// Security expectation: IPv6 loopback must NOT be allowed.
const loopback = out.find((row) => row.url === 'http://[::1]');
if (loopback?.allowed) {
console.error('FINDING_CONFIRMED: normalizeBackupEndpointUrl accepts http://[::1]');
process.exitCode = 2;
} else {
console.log('IPv6 loopback rejected as expected');
}
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.3'; export const APP_VERSION = '1.7.4';
+47 -14
View File
@@ -9,6 +9,34 @@ function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
} }
async function requireMasterPasswordHash(
env: Env,
actorUser: User,
masterPasswordHash: unknown
): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function readJsonBody(request: Request): Promise<Record<string, unknown>> {
try {
const body = await request.json();
return body && typeof body === 'object' && !Array.isArray(body)
? body as Record<string, unknown>
: {};
} catch {
return {};
}
}
function randomHex(bytes: number): string { function randomHex(bytes: number): string {
const data = crypto.getRandomValues(new Uint8Array(bytes)); const data = crypto.getRandomValues(new Uint8Array(bytes));
return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join(''); return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join('');
@@ -204,14 +232,11 @@ export async function handleAdminCreateInvite(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let body: { expiresInHours?: number } = {}; const body = await readJsonBody(request);
try { const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
body = await request.json(); if (passwordError) return passwordError;
} catch {
body = {};
}
const expiresInHours = Number.isFinite(body.expiresInHours) const expiresInHours = Number.isFinite(Number(body.expiresInHours))
? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours)))) ? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours))))
: 24 * 7; : 24 * 7;
const now = new Date(); const now = new Date();
@@ -266,6 +291,10 @@ export async function handleAdminDeleteInvite(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const deleted = await storage.deleteInvite(code); const deleted = await storage.deleteInvite(code);
if (!deleted) { if (!deleted) {
@@ -288,6 +317,10 @@ export async function handleAdminDeleteAllInvites(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const url = new URL(request.url); const url = new URL(request.url);
if (url.searchParams.get('scope') === 'invalid') { if (url.searchParams.get('scope') === 'invalid') {
@@ -318,12 +351,9 @@ export async function handleAdminSetUserStatus(
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
let body: { status?: string }; const body = await readJsonBody(request);
try { const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
body = await request.json(); if (passwordError) return passwordError;
} catch {
return errorResponse('Invalid JSON', 400);
}
const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null; const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null;
if (!nextStatus) { if (!nextStatus) {
@@ -366,7 +396,6 @@ export async function handleAdminDeleteUser(
actorUser: User, actorUser: User,
targetUserId: string targetUserId: string
): Promise<Response> { ): Promise<Response> {
void request;
if (!isAdmin(actorUser)) { if (!isAdmin(actorUser)) {
return errorResponse('Forbidden', 403); return errorResponse('Forbidden', 403);
} }
@@ -374,6 +403,10 @@ export async function handleAdminDeleteUser(
return errorResponse('You cannot delete yourself', 400); return errorResponse('You cannot delete yourself', 400);
} }
const body = await readJsonBody(request);
const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash);
if (passwordError) return passwordError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const target = await storage.getUserById(targetUserId); const target = await storage.getUserById(targetUserId);
if (!target) { if (!target) {
+16 -1
View File
@@ -464,11 +464,26 @@ export async function handleUpdateDeviceName(
// DELETE /api/devices // DELETE /api/devices
export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let masterPasswordHash = '';
try {
const body = await request.json() as { masterPasswordHash?: string };
masterPasswordHash = String(body?.masterPasswordHash || '').trim();
} catch {
masterPasswordHash = '';
}
if (!masterPasswordHash) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
if (!passwordValid) {
return errorResponse('Invalid password', 400);
}
const [removedTrusted, removedSessions, removedDevices] = await Promise.all([ const [removedTrusted, removedSessions, removedDevices] = await Promise.all([
storage.deleteTrustedTwoFactorTokensByUserId(userId), storage.deleteTrustedTwoFactorTokensByUserId(userId),
storage.deleteRefreshTokensByUserId(userId), storage.deleteRefreshTokensByUserId(userId),
+54 -5
View File
@@ -99,23 +99,72 @@ function isBlockedIpv4Address(octets: number[]): boolean {
); );
} }
/**
* Expand a hostname-form IPv6 literal to eight 4-digit hextets.
* Needed so compressed forms like "::1" are not misclassified by a naive
* "first non-empty hextet" check (which would read "1" and miss loopback).
*/
function expandIpv6Address(hostname: string): string[] | null {
const normalized = hostname.trim().toLowerCase().replace(/^\[|\]$/g, '');
if (!normalized.includes(':')) return null;
if (normalized.includes('.')) {
// IPv4-embedded forms are handled separately by the caller.
return null;
}
if ((normalized.match(/::/g) || []).length > 1) return null;
const sides = normalized.split('::');
const left = sides[0] ? sides[0].split(':').filter((part) => part.length > 0) : [];
const right = sides.length > 1 && sides[1] ? sides[1].split(':').filter((part) => part.length > 0) : [];
if (left.length + right.length > 8) return null;
if (sides.length === 1 && left.length !== 8) return null;
const missing = 8 - left.length - right.length;
if (sides.length > 1 && missing < 0) return null;
const middle = sides.length > 1 ? Array.from({ length: missing }, () => '0') : [];
const parts = [...left, ...middle, ...right];
if (parts.length !== 8) return null;
const hextets: string[] = [];
for (const part of parts) {
if (!/^[0-9a-f]{1,4}$/i.test(part)) return null;
hextets.push(part.padStart(4, '0'));
}
return hextets;
}
function isBlockedIpv6Address(hostname: string): boolean { function isBlockedIpv6Address(hostname: string): boolean {
if (!hostname.includes(':')) return false; if (!hostname.includes(':')) return false;
const normalized = hostname.toLowerCase(); const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
// IPv4-mapped dotted form: ::ffff:127.0.0.1
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i);
if (mappedIpv4) { if (mappedIpv4) {
const octets = parseIpv4Address(mappedIpv4[1]); const octets = parseIpv4Address(mappedIpv4[1]);
return !octets || isBlockedIpv4Address(octets); return !octets || isBlockedIpv4Address(octets);
} }
const firstHextetText = normalized.split(':').find((part) => part.length > 0) || '0';
const firstHextet = Number.parseInt(firstHextetText, 16); // IPv4-mapped hex form produced by some URL parsers: ::ffff:7f00:1
const mappedHex = normalized.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (mappedHex) {
const hi = Number.parseInt(mappedHex[1], 16);
const lo = Number.parseInt(mappedHex[2], 16);
if (!Number.isFinite(hi) || !Number.isFinite(lo)) return true;
const octets = [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff];
return isBlockedIpv4Address(octets);
}
const hextets = expandIpv6Address(normalized);
if (!hextets) return true;
const firstHextet = Number.parseInt(hextets[0], 16);
if (!Number.isFinite(firstHextet)) return true; if (!Number.isFinite(firstHextet)) return true;
// After expansion, loopback (::1) and unspecified (::) have first hextet 0.
return ( return (
firstHextet === 0 || firstHextet === 0 ||
(firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xfe00) === 0xfc00 ||
(firstHextet & 0xffc0) === 0xfe80 || (firstHextet & 0xffc0) === 0xfe80 ||
(firstHextet & 0xff00) === 0xff00 || (firstHextet & 0xff00) === 0xff00 ||
normalized.startsWith('2001:db8:') hextets.join(':').startsWith('2001:0db8:')
); );
} }
+2
View File
@@ -1856,6 +1856,8 @@ export default function App() {
}); });
const adminActions = useAdminActions({ const adminActions = useAdminActions({
authedFetch, authedFetch,
email: String(profile?.email || session?.email || ''),
defaultKdfIterations,
onNotify: pushToast, onNotify: pushToast,
onSetConfirm: setConfirm, onSetConfirm: setConfirm,
refetchUsers: usersQuery.refetch, refetchUsers: usersQuery.refetch,
+32 -3
View File
@@ -12,7 +12,9 @@ export interface AppConfirmState {
confirmText?: string; confirmText?: string;
cancelText?: string; cancelText?: string;
hideCancel?: boolean; hideCancel?: boolean;
onConfirm: () => void; /** When true, dialog shows a master-password field and passes it to onConfirm. */
requireMasterPassword?: boolean;
onConfirm: (masterPassword?: string) => void;
onCancel?: () => void; onCancel?: () => void;
} }
@@ -63,6 +65,7 @@ function twoFactorProviderLabel(providerType: number): string {
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const [methodChooserOpen, setMethodChooserOpen] = useState(false); const [methodChooserOpen, setMethodChooserOpen] = useState(false);
const [confirmPassword, setConfirmPassword] = useState('');
const availableProviders = useMemo( const availableProviders = useMemo(
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders), () => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
[props.pendingTotpAvailableProviders] [props.pendingTotpAvailableProviders]
@@ -70,11 +73,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType); const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY; const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN; const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
const requireMasterPassword = !!props.confirm?.requireMasterPassword;
useEffect(() => { useEffect(() => {
setMethodChooserOpen(false); setMethodChooserOpen(false);
}, [props.pendingTotpOpen, props.pendingTotpProviderType]); }, [props.pendingTotpOpen, props.pendingTotpProviderType]);
useEffect(() => {
setConfirmPassword('');
}, [props.confirm?.title, props.confirm?.message, requireMasterPassword]);
return ( return (
<> <>
<ConfirmDialog <ConfirmDialog
@@ -86,9 +94,30 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
confirmText={props.confirm?.confirmText} confirmText={props.confirm?.confirmText}
cancelText={props.confirm?.cancelText} cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel} hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()} confirmDisabled={requireMasterPassword && !confirmPassword.trim()}
onCancel={props.confirm?.onCancel || props.onCancelConfirm} onConfirm={() => {
if (requireMasterPassword && !confirmPassword.trim()) return;
props.confirm?.onConfirm(requireMasterPassword ? confirmPassword : undefined);
setConfirmPassword('');
}}
onCancel={() => {
setConfirmPassword('');
(props.confirm?.onCancel || props.onCancelConfirm)();
}}
>
{requireMasterPassword && (
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={confirmPassword}
onInput={(e) => setConfirmPassword((e.currentTarget as HTMLInputElement).value)}
/> />
</label>
)}
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={props.pendingTotpOpen} open={props.pendingTotpOpen}
@@ -561,13 +561,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
openRemoveAllDevices() { openRemoveAllDevices() {
onSetConfirm({ onSetConfirm({
title: t('txt_remove_all_devices'), title: t('txt_remove_all_devices'),
message: t('txt_remove_all_devices_and_sign_out_all_sessions'), message: `${t('txt_remove_all_devices_and_sign_out_all_sessions')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteAllAuthorizedDevices(authedFetch); if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
await deleteAllAuthorizedDevices(authedFetch, derived.hash);
onNotify('success', t('txt_all_devices_removed')); onNotify('success', t('txt_all_devices_removed'));
onLogoutNow(); onLogoutNow();
} catch (error) { } catch (error) {
+68 -16
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin'; import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin';
import { deriveLoginHash } from '@/lib/api/auth';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays'; import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared'; import type { AuthedFetch } from '@/lib/api/shared';
@@ -8,6 +9,8 @@ type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
interface UseAdminActionsOptions { interface UseAdminActionsOptions {
authedFetch: AuthedFetch; authedFetch: AuthedFetch;
email: string;
defaultKdfIterations: number;
onNotify: Notify; onNotify: Notify;
onSetConfirm: (next: AppConfirmState | null) => void; onSetConfirm: (next: AppConfirmState | null) => void;
refetchUsers: () => Promise<unknown>; refetchUsers: () => Promise<unknown>;
@@ -15,7 +18,24 @@ interface UseAdminActionsOptions {
} }
export default function useAdminActions(options: UseAdminActionsOptions) { export default function useAdminActions(options: UseAdminActionsOptions) {
const { authedFetch, onNotify, onSetConfirm, refetchUsers, refetchInvites } = options; const {
authedFetch,
email,
defaultKdfIterations,
onNotify,
onSetConfirm,
refetchUsers,
refetchInvites,
} = options;
async function withMasterPasswordHash(masterPassword: string | undefined): Promise<string> {
const normalizedEmail = String(email || '').trim().toLowerCase();
const normalizedPassword = String(masterPassword || '');
if (!normalizedEmail) throw new Error(t('txt_profile_unavailable'));
if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(normalizedEmail, normalizedPassword, defaultKdfIterations);
return derived.hash;
}
return useMemo( return useMemo(
() => ({ () => ({
@@ -26,35 +46,61 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
}, },
async createInvite(hours: number) { async createInvite(hours: number) {
onSetConfirm({
title: t('txt_create_timed_invite'),
message: t('txt_enter_master_password_to_continue'),
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try { try {
await createInvite(authedFetch, hours); const hash = await withMasterPasswordHash(masterPassword);
await createInvite(authedFetch, hours, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invite_created')); onNotify('success', t('txt_invite_created'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed'));
} }
})();
},
});
}, },
async toggleUserStatus(userId: string, status: 'active' | 'banned') { async toggleUserStatus(userId: string, status: 'active' | 'banned') {
const nextStatus = status === 'active' ? 'banned' : 'active';
onSetConfirm({
title: nextStatus === 'banned' ? t('txt_ban') : t('txt_unban'),
message: t('txt_enter_master_password_to_continue'),
danger: nextStatus === 'banned',
requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null);
void (async () => {
try { try {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); const hash = await withMasterPasswordHash(masterPassword);
await setUserStatus(authedFetch, userId, nextStatus, hash);
await refetchUsers(); await refetchUsers();
onNotify('success', t('txt_user_status_updated')); onNotify('success', t('txt_user_status_updated'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed'));
} }
})();
},
});
}, },
async deleteInvite(code: string) { async deleteInvite(code: string) {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_invite'), title: t('txt_delete_invite'),
message: t('txt_delete_invite_confirm_message'), message: `${t('txt_delete_invite_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteInvite(authedFetch, code); const hash = await withMasterPasswordHash(masterPassword);
await deleteInvite(authedFetch, code, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invite_deleted')); onNotify('success', t('txt_invite_deleted'));
} catch (error) { } catch (error) {
@@ -68,13 +114,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteInvalidInvites() { async deleteInvalidInvites() {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_invalid_invites'), title: t('txt_delete_invalid_invites'),
message: t('txt_delete_invalid_invites_confirm_message'), message: `${t('txt_delete_invalid_invites_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteInvalidInvites(authedFetch); const hash = await withMasterPasswordHash(masterPassword);
await deleteInvalidInvites(authedFetch, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_invalid_invites_deleted')); onNotify('success', t('txt_invalid_invites_deleted'));
} catch (error) { } catch (error) {
@@ -88,13 +136,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteAllInvites() { async deleteAllInvites() {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_all_invites'), title: t('txt_delete_all_invites'),
message: t('txt_delete_all_invite_codes_active_inactive'), message: `${t('txt_delete_all_invite_codes_active_inactive')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteAllInvites(authedFetch); const hash = await withMasterPasswordHash(masterPassword);
await deleteAllInvites(authedFetch, hash);
await refetchInvites(); await refetchInvites();
onNotify('success', t('txt_all_invites_deleted')); onNotify('success', t('txt_all_invites_deleted'));
} catch (error) { } catch (error) {
@@ -108,13 +158,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
async deleteUser(userId: string) { async deleteUser(userId: string) {
onSetConfirm({ onSetConfirm({
title: t('txt_delete_user'), title: t('txt_delete_user'),
message: t('txt_delete_this_user_and_all_user_data'), message: `${t('txt_delete_this_user_and_all_user_data')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true, danger: true,
onConfirm: () => { requireMasterPassword: true,
onConfirm: (masterPassword) => {
onSetConfirm(null); onSetConfirm(null);
void (async () => { void (async () => {
try { try {
await deleteUser(authedFetch, userId); const hash = await withMasterPasswordHash(masterPassword);
await deleteUser(authedFetch, userId, hash);
await refetchUsers(); await refetchUsers();
onNotify('success', t('txt_user_deleted')); onNotify('success', t('txt_user_deleted'));
} catch (error) { } catch (error) {
@@ -125,6 +177,6 @@ export default function useAdminActions(options: UseAdminActionsOptions) {
}); });
}, },
}), }),
[authedFetch, onNotify, onSetConfirm, refetchInvites, refetchUsers] [authedFetch, defaultKdfIterations, email, onNotify, onSetConfirm, refetchInvites, refetchUsers]
); );
} }
+29 -12
View File
@@ -15,45 +15,62 @@ export async function listAdminInvites(authedFetch: AuthedFetch): Promise<AdminI
return body?.data || []; return body?.data || [];
} }
export async function createInvite(authedFetch: AuthedFetch, hours: number): Promise<void> { export async function createInvite(authedFetch: AuthedFetch, hours: number, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites', { const resp = await authedFetch('/api/admin/invites', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresInHours: hours }), body: JSON.stringify({ expiresInHours: hours, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error('Create invite failed'); if (!resp.ok) throw new Error('Create invite failed');
} }
export async function deleteInvite(authedFetch: AuthedFetch, code: string): Promise<void> { export async function deleteInvite(authedFetch: AuthedFetch, code: string, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete invite failed'); if (!resp.ok) throw new Error('Delete invite failed');
} }
export async function deleteInvalidInvites(authedFetch: AuthedFetch): Promise<void> { export async function deleteInvalidInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' }); const resp = await authedFetch('/api/admin/invites?scope=invalid', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete invalid invites failed'); if (!resp.ok) throw new Error('Delete invalid invites failed');
} }
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' }); const resp = await authedFetch('/api/admin/invites', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete all invites failed'); if (!resp.ok) throw new Error('Delete all invites failed');
} }
export async function setUserStatus( export async function setUserStatus(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
userId: string, userId: string,
status: 'active' | 'banned' status: 'active' | 'banned',
masterPasswordHash: string
): Promise<void> { ): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, { const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }), body: JSON.stringify({ status, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error('Update user status failed'); if (!resp.ok) throw new Error('Update user status failed');
} }
export async function deleteUser(authedFetch: AuthedFetch, userId: string): Promise<void> { export async function deleteUser(authedFetch: AuthedFetch, userId: string, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }); const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error('Delete user failed'); if (!resp.ok) throw new Error('Delete user failed');
} }
+6 -2
View File
@@ -1140,8 +1140,12 @@ export async function updateAuthorizedDeviceName(
if (!resp.ok) throw new Error(t('txt_update_device_note_failed')); if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
} }
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> { export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
const resp = await authedFetch('/api/devices', { method: 'DELETE' }); const resp = await authedFetch('/api/devices', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
} }