diff --git a/.gitignore b/.gitignore index dfcca5c..b1dd2e9 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ hermes-dependencies.md *.sln *.sw? .superpowers/ +CLAUDE.md +# Client source map artifacts +packages/client/src/**/*.js \ No newline at end of file diff --git a/packages/client/src/api/auth.ts b/packages/client/src/api/auth.ts index 92bb213..7f0c608 100644 --- a/packages/client/src/api/auth.ts +++ b/packages/client/src/api/auth.ts @@ -19,7 +19,9 @@ export async function loginWithPassword(username: string, password: string): Pro }) if (!res.ok) { const data = await res.json().catch(() => ({})) - throw new Error(data.error || 'Login failed') + const err: any = new Error(data.error || 'Login failed') + err.status = res.status + throw err } const data = await res.json() return data.token @@ -51,3 +53,28 @@ export async function removePassword(): Promise { method: 'DELETE', }) } + +export interface LockedIp { + ip: string + type: 'password' | 'token' + failures: number + lockedUntil: number +} + +export async function fetchLockedIps(): Promise { + const res = await request<{ locks: LockedIp[] }>('/api/auth/locked-ips') + return res.locks +} + +export async function unlockSpecificIp(ip: string): Promise { + return request(`/api/auth/locked-ips?ip=${encodeURIComponent(ip)}`, { + method: 'DELETE', + }) +} + +export async function unlockAllIps(): Promise { + const res = await request<{ count: number }>('/api/auth/locked-ips', { + method: 'DELETE', + }) + return res.count +} diff --git a/packages/client/src/components/hermes/settings/AccountSettings.vue b/packages/client/src/components/hermes/settings/AccountSettings.vue index 038972b..7a18f50 100644 --- a/packages/client/src/components/hermes/settings/AccountSettings.vue +++ b/packages/client/src/components/hermes/settings/AccountSettings.vue @@ -2,7 +2,8 @@ import { ref, onMounted } from "vue"; import { NButton, NInput, NModal, NForm, NFormItem, NPopconfirm, useMessage } from "naive-ui"; import { useI18n } from "vue-i18n"; -import { fetchAuthStatus, setupPassword, changePassword, changeUsername, removePassword } from "@/api/auth"; +import { fetchAuthStatus, setupPassword, changePassword, changeUsername, removePassword, fetchLockedIps, unlockSpecificIp, unlockAllIps } from "@/api/auth"; +import type { LockedIp } from "@/api/auth"; const { t } = useI18n(); const message = useMessage(); @@ -139,6 +140,47 @@ function openChangeUsernameModal() { newUsernameVal.value = ""; showChangeUsernameModal.value = true; } + +// Locked IPs management +const lockedIps = ref([]); +const loadingLocks = ref(false); + +async function loadLockedIps() { + loadingLocks.value = true; + try { + lockedIps.value = await fetchLockedIps(); + } catch { /* ignore */ } + finally { + loadingLocks.value = false; + } +} + +async function handleUnlockIp(ip: string) { + try { + await unlockSpecificIp(ip); + message.success(t("settings.lockedIps.unlocked")); + await loadLockedIps(); + } catch (err: any) { + message.error(err.message || t("common.saveFailed")); + } +} + +async function handleUnlockAll() { + try { + const count = await unlockAllIps(); + message.success(t("settings.lockedIps.allUnlocked", { count })); + await loadLockedIps(); + } catch (err: any) { + message.error(err.message || t("common.saveFailed")); + } +} + +function formatTime(ts: number): string { + const remaining = Math.max(0, Math.round((ts - Date.now()) / 60000)); + return remaining > 0 ? `${remaining} min` : t("common.expired"); +} + +onMounted(() => { loadLockedIps(); });