feat(auth): add device session tracking with configurable limits
Track authenticated sessions per device (parsing user-agent for desktop vs mobile via mileusna/useragent), enforce configurable concurrent device limits (Auth.DeviceLimits), and surface device status and "remove other devices" management in the account dialog. Adds device/session context to the auth module stores and exposes limits through the API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,3 +63,8 @@ export type AccountDevice = {
|
||||
client: string;
|
||||
lastSeenAt: string;
|
||||
};
|
||||
|
||||
export type AccountDeviceLimits = {
|
||||
desktop: number;
|
||||
mobile: number;
|
||||
};
|
||||
|
||||
@@ -19,16 +19,27 @@
|
||||
"username": "Username",
|
||||
"email": "Email",
|
||||
"deviceManagement": "Device Management",
|
||||
"removeAllDevices": "Remove All Devices",
|
||||
"removeAllDevicesDescription": "A Moteva account is for personal use only. To prevent abuse and keep the platform stable, tasks can run in up to 2 desktop web sessions and 1 mobile web session at the same time. For team or multi-person use, please purchase Team Plan.",
|
||||
"removeAllDevices": "Remove Other Devices",
|
||||
"removeAllDevicesDescription": "A Moteva account is for personal use only. To prevent abuse and keep the platform stable, tasks can run in up to {desktop} desktop web sessions and {mobile} mobile web sessions at the same time. For team or multi-person use, please purchase Team Plan.",
|
||||
"deviceLimitsLoading": "Loading the current device policy.",
|
||||
"deviceLimitsUnavailable": "The current device policy is temporarily unavailable.",
|
||||
"onlineStatus": "Online",
|
||||
"deviceInfo": "Device Info",
|
||||
"deviceStatus": "Device status",
|
||||
"currentDevice": "Current Device",
|
||||
"system": "System",
|
||||
"currentLoginAccount": "Current account: {name}",
|
||||
"accountDeviceDesktop": "Desktop",
|
||||
"accountDeviceMobile": "Mobile",
|
||||
"accountDeviceUnknownSystem": "Unknown system",
|
||||
"accountDeviceUnknownBrowser": "Unknown browser",
|
||||
"deviceOnline": "Online",
|
||||
"deviceOffline": "Offline",
|
||||
"deviceLastSeen": "Last active {time}",
|
||||
"deviceListLoading": "Loading devices",
|
||||
"deviceListLoadFailed": "Could not load devices",
|
||||
"noActiveDevices": "No active devices",
|
||||
"retry": "Retry",
|
||||
"avatarImageRequired": "Please choose an image file.",
|
||||
"avatarTooLarge": "Avatar image must be under 2 MB.",
|
||||
"avatarUpdateFailed": "Failed to update avatar. Please choose the image again.",
|
||||
@@ -37,9 +48,11 @@
|
||||
"usernameUpdated": "Username updated.",
|
||||
"usernameUpdateFailed": "Failed to update username. Please try again later.",
|
||||
"currentDeviceNotice": "This is the device you are using now.",
|
||||
"devicesRemoved": "{count} device(s) removed.",
|
||||
"removeAllDevicesConfirmTitle": "Remove all devices?",
|
||||
"removeAllDevicesConfirmText": "After confirmation, your current session will be removed and you will need to log in again.",
|
||||
"devicesRemoved": "{count} other device(s) removed.",
|
||||
"devicesRemoving": "Removing",
|
||||
"devicesRemoveFailed": "Could not remove the other devices. Please try again.",
|
||||
"removeAllDevicesConfirmTitle": "Remove other devices?",
|
||||
"removeAllDevicesConfirmText": "This will sign out {count} other device(s). Your current device will stay signed in.",
|
||||
"guides": "Guides",
|
||||
"contactUs": "Contact us",
|
||||
"logout": "Log out",
|
||||
|
||||
@@ -19,16 +19,27 @@
|
||||
"username": "用户名",
|
||||
"email": "电子邮箱",
|
||||
"deviceManagement": "设备管理",
|
||||
"removeAllDevices": "移除全部设备",
|
||||
"removeAllDevicesDescription": "Moteva 账户仅限个人使用。为防止滥用并确保平台稳定,任务最多可同时在 2 个桌面 Web 会话和 1 个移动 Web 会话中运行。如需团队或多人使用,请购买 Team Plan。",
|
||||
"removeAllDevices": "移除其他设备",
|
||||
"removeAllDevicesDescription": "Moteva 账户仅限个人使用。为防止滥用并确保平台稳定,任务最多可同时在 {desktop} 个桌面 Web 会话和 {mobile} 个移动 Web 会话中运行。如需团队或多人使用,请购买 Team Plan。",
|
||||
"deviceLimitsLoading": "正在加载当前设备策略。",
|
||||
"deviceLimitsUnavailable": "当前设备策略暂时无法加载。",
|
||||
"onlineStatus": "在线状态",
|
||||
"deviceInfo": "设备信息",
|
||||
"deviceStatus": "设备状态",
|
||||
"currentDevice": "当前设备",
|
||||
"system": "系统",
|
||||
"currentLoginAccount": "当前登录账号:{name}",
|
||||
"accountDeviceDesktop": "桌面端",
|
||||
"accountDeviceMobile": "移动端",
|
||||
"accountDeviceUnknownSystem": "未知系统",
|
||||
"accountDeviceUnknownBrowser": "未知浏览器",
|
||||
"deviceOnline": "在线",
|
||||
"deviceOffline": "离线",
|
||||
"deviceLastSeen": "最近活跃 {time}",
|
||||
"deviceListLoading": "正在加载设备",
|
||||
"deviceListLoadFailed": "设备列表加载失败",
|
||||
"noActiveDevices": "暂无活跃设备",
|
||||
"retry": "重试",
|
||||
"avatarImageRequired": "请选择图片文件。",
|
||||
"avatarTooLarge": "头像图片不能超过 2MB。",
|
||||
"avatarUpdateFailed": "头像更新失败,请重新选择图片。",
|
||||
@@ -37,9 +48,11 @@
|
||||
"usernameUpdated": "用户名已更新。",
|
||||
"usernameUpdateFailed": "用户名更新失败,请稍后重试。",
|
||||
"currentDeviceNotice": "这是当前正在使用的设备。",
|
||||
"devicesRemoved": "已移除 {count} 台设备。",
|
||||
"removeAllDevicesConfirmTitle": "移除全部设备?",
|
||||
"removeAllDevicesConfirmText": "确认后将移除当前登录状态,并需要重新登录。",
|
||||
"devicesRemoved": "已移除 {count} 个其他设备。",
|
||||
"devicesRemoving": "正在移除",
|
||||
"devicesRemoveFailed": "设备移除失败,请稍后重试。",
|
||||
"removeAllDevicesConfirmTitle": "移除其他设备?",
|
||||
"removeAllDevicesConfirmText": "将退出 {count} 个其他设备上的登录状态,当前设备会保持登录。",
|
||||
"guides": "使用指南",
|
||||
"contactUs": "联系我们",
|
||||
"logout": "退出登录",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AccountDevice, AuthCodeResponse, AuthOptions, AuthSession, AuthUser, StoredAuthSession } from "@/domain/auth";
|
||||
import type { AccountDevice, AccountDeviceLimits, AuthCodeResponse, AuthOptions, AuthSession, AuthUser, StoredAuthSession } from "@/domain/auth";
|
||||
import { createApiFailure, createNetworkFailure } from "@/infrastructure/userMessages";
|
||||
|
||||
const sessionStorageKey = "img-infinite-canvas.auth.session";
|
||||
@@ -100,7 +100,7 @@ export const authGateway = {
|
||||
},
|
||||
|
||||
devices() {
|
||||
return apiRequest<{ devices: AccountDevice[] }>("/api/account/devices");
|
||||
return apiRequest<{ devices: AccountDevice[]; limits: AccountDeviceLimits }>("/api/account/devices");
|
||||
},
|
||||
|
||||
removeDevices() {
|
||||
|
||||
@@ -348,6 +348,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #e0e3e8;
|
||||
border-radius: 8px;
|
||||
@@ -371,27 +372,21 @@
|
||||
}
|
||||
|
||||
.account-management-button.danger {
|
||||
border-color: transparent;
|
||||
color: #8d939d;
|
||||
background: rgba(17, 24, 39, 0.06);
|
||||
border-color: #efc8c2;
|
||||
color: #b53b2c;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.account-management-button.danger:hover:not(:disabled) {
|
||||
color: #d64d34;
|
||||
background: rgba(214, 77, 52, 0.1);
|
||||
border-color: #e4ada4;
|
||||
color: #a63225;
|
||||
background: #fdf3f1;
|
||||
}
|
||||
|
||||
.account-management-button.small {
|
||||
min-width: auto;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.account-management-button.current {
|
||||
.account-management-button.danger:disabled {
|
||||
border-color: transparent;
|
||||
color: #aeb4bd;
|
||||
background: rgba(17, 24, 39, 0.03);
|
||||
background: rgba(17, 24, 39, 0.05);
|
||||
}
|
||||
|
||||
.account-management-device-action {
|
||||
@@ -424,62 +419,221 @@
|
||||
}
|
||||
|
||||
.account-management-device-table {
|
||||
display: grid;
|
||||
grid-template-columns: 100px minmax(0, 1fr) 120px;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.account-management-device-col {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.account-management-device-header,
|
||||
.account-management-device-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px minmax(0, 1fr) 112px;
|
||||
}
|
||||
|
||||
.account-management-device-head,
|
||||
.account-management-device-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.account-management-device-header {
|
||||
min-height: 38px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.account-management-device-head {
|
||||
height: 36px;
|
||||
color: #737a86;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.account-management-device-col.info .account-management-device-head,
|
||||
.account-management-device-col.info .account-management-device-cell {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.account-management-device-col.action .account-management-device-cell {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.account-management-device-cell {
|
||||
height: 48px;
|
||||
color: #20242c;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.account-management-device-cell span {
|
||||
.account-management-device-header > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.account-management-device-header > span:first-child {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.account-management-device-row {
|
||||
min-height: 62px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
color: #20242c;
|
||||
}
|
||||
|
||||
.account-management-device-status,
|
||||
.account-management-device-info,
|
||||
.account-management-device-row-action {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.account-management-device-status {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.account-management-device-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
background: #b7bdc6;
|
||||
box-shadow: 0 0 0 3px rgba(183, 189, 198, 0.16);
|
||||
}
|
||||
|
||||
.account-management-device-dot.online {
|
||||
background: #2fc36c;
|
||||
box-shadow: 0 0 0 3px rgba(47, 195, 108, 0.14);
|
||||
}
|
||||
|
||||
.account-management-device-info {
|
||||
gap: 10px;
|
||||
padding: 9px 12px 9px 0;
|
||||
}
|
||||
|
||||
.account-management-device-info > svg {
|
||||
flex: 0 0 auto;
|
||||
color: #7a818c;
|
||||
}
|
||||
|
||||
.account-management-device-info > span {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.account-management-device-info strong,
|
||||
.account-management-device-info small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-management-online-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-left: 9px;
|
||||
.account-management-device-info strong {
|
||||
color: #262a31;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
font-weight: 550;
|
||||
}
|
||||
|
||||
.account-management-device-info small {
|
||||
color: #818895;
|
||||
font-size: 11px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.account-management-device-row-action {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.account-management-device-current {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #e1e4e9;
|
||||
border-radius: 8px;
|
||||
color: #8d949f;
|
||||
background: #fafafa;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.account-management-device-state {
|
||||
min-height: 88px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.account-management-device-state > div {
|
||||
min-height: 88px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
color: #777f8b;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.account-management-device-state svg {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.account-management-device-retry {
|
||||
min-height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 4px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #dfe3e8;
|
||||
border-radius: 7px;
|
||||
color: #343943;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.account-management-device-retry:hover {
|
||||
background: #f6f7f8;
|
||||
}
|
||||
|
||||
.account-management-device-skeleton {
|
||||
display: block;
|
||||
border-radius: 5px;
|
||||
background: #eceef1;
|
||||
animation: account-management-device-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.account-management-device-skeleton.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #31cf74;
|
||||
}
|
||||
|
||||
.account-management-device-skeleton.icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.account-management-device-skeleton.line {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.account-management-device-skeleton.line.primary {
|
||||
width: min(230px, 42vw);
|
||||
}
|
||||
|
||||
.account-management-device-skeleton.line.secondary {
|
||||
width: min(170px, 32vw);
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.account-management-device-skeleton.badge {
|
||||
width: 70px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.account-management-sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
border: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes account-management-device-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.account-management-logout-copy {
|
||||
@@ -540,6 +694,10 @@
|
||||
padding: 0 14px;
|
||||
border: 1px solid #e0e3e8;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
color: #20242c;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
@@ -552,6 +710,11 @@
|
||||
background: #d64d34;
|
||||
}
|
||||
|
||||
.account-management-confirm button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.account-management-backdrop {
|
||||
padding: 16px;
|
||||
@@ -590,9 +753,40 @@
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.account-management-device-table {
|
||||
grid-template-columns: 76px minmax(220px, 1fr) 106px;
|
||||
overflow-x: auto;
|
||||
.account-management-device-action .account-management-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.account-management-device-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.account-management-device-row {
|
||||
grid-template-columns: 26px minmax(0, 1fr);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.account-management-device-status {
|
||||
align-items: flex-start;
|
||||
padding: 7px 0 0 4px;
|
||||
}
|
||||
|
||||
.account-management-device-info {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.account-management-device-row-action {
|
||||
grid-column: 2;
|
||||
justify-content: flex-start;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.account-management-device-row.loading .account-management-device-row-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.account-management-device-state > div {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.account-management-name-form {
|
||||
@@ -605,3 +799,9 @@
|
||||
width: min(320px, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.account-management-device-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { CircleUserRound, PencilLine, ReceiptText, X, Zap } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type FormEvent, type ReactNode } from "react";
|
||||
import { AlertCircle, CircleUserRound, Loader2, Monitor, MonitorX, PencilLine, ReceiptText, RefreshCw, Smartphone, X, Zap } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type FormEvent, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import type { AccountDevice, AuthUser } from "@/domain/auth";
|
||||
import type { AccountDevice, AccountDeviceLimits, AuthUser } from "@/domain/auth";
|
||||
import { useI18n } from "@/i18n/i18n";
|
||||
import { authGateway } from "@/infrastructure/authGateway";
|
||||
|
||||
@@ -15,12 +15,18 @@ type AccountManagementDialogProps = {
|
||||
onUserUpdate: (userPatch: Partial<AuthUser>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type DeviceLoadState = "loading" | "ready" | "error";
|
||||
|
||||
export function AccountManagementDialog({ open, user, onClose, onLogout, onUserUpdate }: AccountManagementDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const deviceRequestRef = useRef(0);
|
||||
const scrollRevealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [confirmDeviceRemoval, setConfirmDeviceRemoval] = useState(false);
|
||||
const [devices, setDevices] = useState<AccountDevice[]>([]);
|
||||
const [deviceLimits, setDeviceLimits] = useState<AccountDeviceLimits | null>(null);
|
||||
const [deviceLoadState, setDeviceLoadState] = useState<DeviceLoadState>("loading");
|
||||
const [deviceRemoving, setDeviceRemoving] = useState(false);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [nameDraft, setNameDraft] = useState("");
|
||||
const [feedback, setFeedback] = useState<{ tone: "success" | "error"; message: string } | null>(null);
|
||||
@@ -30,15 +36,36 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
const displayEmail = user?.email?.trim() || "";
|
||||
const displayAvatar = cleanAvatarUrl(user?.avatarUrl);
|
||||
const avatarLabel = avatarInitials(displayName || displayEmail);
|
||||
const loginTimestamp = useMemo(() => formatUtcTimestamp(new Date()), []);
|
||||
const accountDevices = devices.length > 0 ? devices : [defaultCurrentDevice(loginTimestamp)];
|
||||
const hasRemovableDevices = accountDevices.some((device) => !device.current);
|
||||
const removableDeviceCount = devices.filter((device) => !device.current).length;
|
||||
const hasRemovableDevices = deviceLoadState === "ready" && removableDeviceCount > 0;
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
const requestId = ++deviceRequestRef.current;
|
||||
setDeviceLoadState("loading");
|
||||
try {
|
||||
const { devices: nextDevices, limits } = await authGateway.devices();
|
||||
if (deviceRequestRef.current !== requestId) return;
|
||||
setDevices(nextDevices);
|
||||
setDeviceLimits(limits);
|
||||
setDeviceLoadState("ready");
|
||||
} catch {
|
||||
if (deviceRequestRef.current !== requestId) return;
|
||||
setDevices([]);
|
||||
setDeviceLimits(null);
|
||||
setDeviceLoadState("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
if (event.key !== "Escape" || deviceRemoving) return;
|
||||
if (confirmDeviceRemoval) {
|
||||
setConfirmDeviceRemoval(false);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const previousDocumentOverflow = document.documentElement.style.overflow;
|
||||
@@ -51,28 +78,23 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
document.documentElement.style.overflow = previousDocumentOverflow;
|
||||
document.removeEventListener("keydown", closeOnEscape);
|
||||
};
|
||||
}, [onClose, open]);
|
||||
}, [confirmDeviceRemoval, deviceRemoving, onClose, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
authGateway
|
||||
.devices()
|
||||
.then(({ devices: nextDevices }) => {
|
||||
if (active) setDevices(nextDevices);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setDevices([defaultCurrentDevice(loginTimestamp)]);
|
||||
});
|
||||
void loadDevices();
|
||||
return () => {
|
||||
active = false;
|
||||
deviceRequestRef.current++;
|
||||
};
|
||||
}, [loginTimestamp, open]);
|
||||
}, [loadDevices, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) return;
|
||||
setConfirmDeviceRemoval(false);
|
||||
setDevices([]);
|
||||
setDeviceLimits(null);
|
||||
setDeviceLoadState("loading");
|
||||
setDeviceRemoving(false);
|
||||
setEditingName(false);
|
||||
setFeedback(null);
|
||||
setProfileSaving(false);
|
||||
@@ -149,11 +171,30 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
};
|
||||
|
||||
const removeAllDevices = async () => {
|
||||
setConfirmDeviceRemoval(false);
|
||||
const response = await authGateway.removeDevices();
|
||||
const { devices: nextDevices } = await authGateway.devices();
|
||||
setDevices(nextDevices);
|
||||
showFeedback("success", t("devicesRemoved", { count: response.removed }));
|
||||
if (deviceRemoving) return;
|
||||
setDeviceRemoving(true);
|
||||
try {
|
||||
const response = await authGateway.removeDevices();
|
||||
setDevices((currentDevices) => currentDevices.filter((device) => device.current));
|
||||
setDeviceLoadState("ready");
|
||||
setConfirmDeviceRemoval(false);
|
||||
showFeedback("success", t("devicesRemoved", { count: response.removed }));
|
||||
|
||||
const requestId = ++deviceRequestRef.current;
|
||||
try {
|
||||
const { devices: nextDevices, limits } = await authGateway.devices();
|
||||
if (deviceRequestRef.current === requestId) {
|
||||
setDevices(nextDevices);
|
||||
setDeviceLimits(limits);
|
||||
}
|
||||
} catch {
|
||||
// The destructive action succeeded; keep the optimistic current-device result.
|
||||
}
|
||||
} catch {
|
||||
showFeedback("error", t("devicesRemoveFailed"));
|
||||
} finally {
|
||||
setDeviceRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revealScrollbarWhileScrolling = () => {
|
||||
@@ -171,11 +212,11 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
role="presentation"
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
if (!deviceRemoving && event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<section className="account-management-dialog" role="dialog" aria-modal="true" aria-label={t("accountManagement")}>
|
||||
<button className="account-management-close" type="button" onClick={onClose} aria-label={t("close")}>
|
||||
<button className="account-management-close" type="button" onClick={onClose} aria-label={t("close")} disabled={deviceRemoving}>
|
||||
<X size={21} strokeWidth={1.8} />
|
||||
</button>
|
||||
|
||||
@@ -269,49 +310,78 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
<div className="account-management-device-action">
|
||||
<div>
|
||||
<strong>{t("removeAllDevices")}</strong>
|
||||
<p>{t("removeAllDevicesDescription")}</p>
|
||||
<p>
|
||||
{deviceLimits
|
||||
? t("removeAllDevicesDescription", { desktop: deviceLimits.desktop, mobile: deviceLimits.mobile })
|
||||
: t(deviceLoadState === "loading" ? "deviceLimitsLoading" : "deviceLimitsUnavailable")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="account-management-button danger"
|
||||
type="button"
|
||||
onClick={() => setConfirmDeviceRemoval(true)}
|
||||
disabled={!hasRemovableDevices}
|
||||
disabled={!hasRemovableDevices || deviceRemoving}
|
||||
>
|
||||
<MonitorX size={16} strokeWidth={1.8} />
|
||||
{t("removeAllDevices")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="account-management-device-table" role="table" aria-label={t("deviceManagement")}>
|
||||
<DeviceColumn
|
||||
title={t("onlineStatus")}
|
||||
className="status"
|
||||
cells={accountDevices.map((device) => <span key={device.id} className={device.online ? "account-management-online-dot" : ""} />)}
|
||||
/>
|
||||
<DeviceColumn
|
||||
title={t("deviceInfo")}
|
||||
className="info"
|
||||
cells={accountDevices.map((device) => {
|
||||
const info = deviceInfoText(device, t);
|
||||
return (
|
||||
<span key={device.id} title={info}>
|
||||
{info}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
<DeviceColumn
|
||||
title=""
|
||||
className="action"
|
||||
cells={accountDevices.map((device) =>
|
||||
device.current ? (
|
||||
<button key={device.id} className="account-management-button small current" type="button" disabled>
|
||||
{t("currentDevice")}
|
||||
<div
|
||||
className="account-management-device-table"
|
||||
role="table"
|
||||
aria-label={t("deviceManagement")}
|
||||
aria-busy={deviceLoadState === "loading" || deviceRemoving}
|
||||
>
|
||||
<div className="account-management-device-header" role="row">
|
||||
<span role="columnheader">{t("onlineStatus")}</span>
|
||||
<span role="columnheader">{t("deviceInfo")}</span>
|
||||
<span role="columnheader" aria-label={t("deviceStatus")} />
|
||||
</div>
|
||||
<div className="account-management-device-body" role="rowgroup">
|
||||
{deviceLoadState === "loading" && <DeviceLoadingRows label={t("deviceListLoading")} />}
|
||||
{deviceLoadState === "error" && (
|
||||
<DeviceTableState icon={<AlertCircle size={18} />} message={t("deviceListLoadFailed")}>
|
||||
<button className="account-management-device-retry" type="button" onClick={() => void loadDevices()}>
|
||||
<RefreshCw size={14} strokeWidth={1.9} />
|
||||
{t("retry")}
|
||||
</button>
|
||||
) : (
|
||||
<span key={device.id} />
|
||||
)
|
||||
</DeviceTableState>
|
||||
)}
|
||||
/>
|
||||
{deviceLoadState === "ready" && devices.length === 0 && (
|
||||
<DeviceTableState icon={<Monitor size={18} />} message={t("noActiveDevices")}>
|
||||
<button className="account-management-device-retry" type="button" onClick={() => void loadDevices()}>
|
||||
<RefreshCw size={14} strokeWidth={1.9} />
|
||||
{t("retry")}
|
||||
</button>
|
||||
</DeviceTableState>
|
||||
)}
|
||||
{deviceLoadState === "ready" &&
|
||||
devices.map((device) => {
|
||||
const primaryInfo = devicePrimaryInfo(device, t);
|
||||
const secondaryInfo = deviceSecondaryInfo(device, t, locale);
|
||||
const onlineLabel = device.online ? t("deviceOnline") : t("deviceOffline");
|
||||
const DeviceIcon = device.deviceType === "mobile" ? Smartphone : Monitor;
|
||||
return (
|
||||
<div className="account-management-device-row" role="row" key={device.id}>
|
||||
<div className="account-management-device-status" role="cell" title={onlineLabel}>
|
||||
<span className={`account-management-device-dot ${device.online ? "online" : "offline"}`} aria-hidden="true" />
|
||||
<span className="account-management-sr-only">{onlineLabel}</span>
|
||||
</div>
|
||||
<div className="account-management-device-info" role="cell">
|
||||
<DeviceIcon size={17} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>
|
||||
<strong title={primaryInfo}>{primaryInfo}</strong>
|
||||
<small title={secondaryInfo}>{secondaryInfo}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="account-management-device-row-action" role="cell">
|
||||
{device.current && <span className="account-management-device-current">{t("currentDevice")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -334,16 +404,23 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
</main>
|
||||
</div>
|
||||
{confirmDeviceRemoval && (
|
||||
<div className="account-management-confirm-backdrop" role="presentation">
|
||||
<div
|
||||
className="account-management-confirm-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (!deviceRemoving && event.target === event.currentTarget) setConfirmDeviceRemoval(false);
|
||||
}}
|
||||
>
|
||||
<div className="account-management-confirm" role="alertdialog" aria-modal="true" aria-label={t("removeAllDevices")}>
|
||||
<h4>{t("removeAllDevicesConfirmTitle")}</h4>
|
||||
<p>{t("removeAllDevicesConfirmText")}</p>
|
||||
<p>{t("removeAllDevicesConfirmText", { count: removableDeviceCount })}</p>
|
||||
<div>
|
||||
<button type="button" onClick={() => setConfirmDeviceRemoval(false)}>
|
||||
<button type="button" onClick={() => setConfirmDeviceRemoval(false)} disabled={deviceRemoving}>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button className="danger" type="button" onClick={() => void removeAllDevices()}>
|
||||
{t("removeAllDevices")}
|
||||
<button className="danger" type="button" onClick={() => void removeAllDevices()} disabled={deviceRemoving}>
|
||||
{deviceRemoving && <Loader2 className="spin" size={15} aria-hidden="true" />}
|
||||
{deviceRemoving ? t("devicesRemoving") : t("removeAllDevices")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,35 +432,38 @@ export function AccountManagementDialog({ open, user, onClose, onLogout, onUserU
|
||||
);
|
||||
}
|
||||
|
||||
function formatUtcTimestamp(date: Date) {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getUTCDate()).padStart(2, "0");
|
||||
const hour = String(date.getUTCHours()).padStart(2, "0");
|
||||
const minute = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day} ${hour}:${minute}(UTC)`;
|
||||
}
|
||||
|
||||
function defaultCurrentDevice(lastSeenAt: string): AccountDevice {
|
||||
return {
|
||||
id: "current",
|
||||
online: true,
|
||||
current: true,
|
||||
deviceType: "desktop",
|
||||
system: "unknown",
|
||||
browser: "unknown",
|
||||
client: "PC-WEB",
|
||||
lastSeenAt
|
||||
};
|
||||
}
|
||||
|
||||
function deviceInfoText(device: AccountDevice, t: (key: string) => string) {
|
||||
const deviceType = device.deviceType === "desktop" ? t("accountDeviceDesktop") : device.deviceType || t("accountDeviceDesktop");
|
||||
function devicePrimaryInfo(device: AccountDevice, t: (key: string, params?: Record<string, string | number>) => string) {
|
||||
const deviceType = device.deviceType === "mobile" ? t("accountDeviceMobile") : t("accountDeviceDesktop");
|
||||
const system = device.system === "unknown" ? t("accountDeviceUnknownSystem") : device.system || t("accountDeviceUnknownSystem");
|
||||
const browser = device.browser === "unknown" ? t("accountDeviceUnknownBrowser") : device.browser || t("accountDeviceUnknownBrowser");
|
||||
return `${deviceType} · ${system} · ${browser}`;
|
||||
}
|
||||
|
||||
return `${deviceType} | ${system} | ${browser} | ${device.client || "PC-WEB"} | | ${device.lastSeenAt}`;
|
||||
function deviceSecondaryInfo(
|
||||
device: AccountDevice,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
locale: string
|
||||
) {
|
||||
const fallbackClient = device.deviceType === "mobile" ? "MOBILE-WEB" : "PC-WEB";
|
||||
return `${device.client || fallbackClient} · ${t("deviceLastSeen", { time: formatDeviceLastSeen(device.lastSeenAt, locale) })}`;
|
||||
}
|
||||
|
||||
function formatDeviceLastSeen(value: string, locale: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "-";
|
||||
const legacyUTC = trimmed.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})(?::(\d{2}))?\(UTC\)$/);
|
||||
const instant = new Date(legacyUTC ? `${legacyUTC[1]}T${legacyUTC[2]}:${legacyUTC[3] || "00"}Z` : trimmed);
|
||||
if (Number.isNaN(instant.getTime())) return trimmed;
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hourCycle: "h23",
|
||||
timeZoneName: "short"
|
||||
}).format(instant);
|
||||
}
|
||||
|
||||
function avatarInitials(value: string) {
|
||||
@@ -401,17 +481,39 @@ function cleanAvatarUrl(value: string | undefined) {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
function DeviceColumn({ title, className, cells }: { title: string; className: string; cells: ReactNode[] }) {
|
||||
function DeviceLoadingRows({ label }: { label: string }) {
|
||||
return (
|
||||
<div className={`account-management-device-col ${className}`}>
|
||||
<div className="account-management-device-head" role="columnheader">
|
||||
{title}
|
||||
</div>
|
||||
{cells.map((cell, index) => (
|
||||
<div className="account-management-device-cell" role="cell" key={index}>
|
||||
{cell}
|
||||
<>
|
||||
{[0, 1].map((index) => (
|
||||
<div className="account-management-device-row loading" role="row" key={index} aria-hidden="true">
|
||||
<div className="account-management-device-status" role="cell">
|
||||
<span className="account-management-device-skeleton dot" />
|
||||
{index === 0 && <span className="account-management-sr-only">{label}</span>}
|
||||
</div>
|
||||
<div className="account-management-device-info" role="cell">
|
||||
<span className="account-management-device-skeleton icon" />
|
||||
<span>
|
||||
<i className="account-management-device-skeleton line primary" />
|
||||
<i className="account-management-device-skeleton line secondary" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="account-management-device-row-action" role="cell">
|
||||
<span className="account-management-device-skeleton badge" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceTableState({ icon, message, children }: { icon: ReactNode; message: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="account-management-device-state" role="row">
|
||||
<div role="cell" aria-colspan={3}>
|
||||
{icon}
|
||||
<span>{message}</span>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user