SECURITY - LAUNCHER: harden storage and access lifecycle
This commit is contained in:
+43
-15
@@ -108,15 +108,19 @@ export function LauncherApp() {
|
||||
const runtimeDataRef = useRef(data);
|
||||
const runtimeProfileIdRef = useRef(activeProfileId);
|
||||
const runtimeClientIdRef = useRef(activeClientId);
|
||||
const resolvedProfileId = useMemo(
|
||||
() => resolveRuntimeProfileId(data, authSession, activeProfileId),
|
||||
[activeProfileId, authSession, data]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
runtimeDataRef.current = data;
|
||||
runtimeProfileIdRef.current = activeProfileId;
|
||||
runtimeProfileIdRef.current = resolvedProfileId;
|
||||
runtimeClientIdRef.current = activeClientId;
|
||||
}, [activeClientId, activeProfileId, data]);
|
||||
}, [activeClientId, data, resolvedProfileId]);
|
||||
|
||||
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
|
||||
const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0];
|
||||
const me = useMemo(() => buildMe(data, resolvedProfileId, activeClientId), [data, resolvedProfileId, activeClientId]);
|
||||
const activeProfileUser = data.users.find((user) => user.id === resolvedProfileId) ?? data.users[0];
|
||||
const currentAccessRequest = useMemo(() => {
|
||||
if (!authSession?.authenticated || !authSession.user.email) return null;
|
||||
|
||||
@@ -150,10 +154,10 @@ export function LauncherApp() {
|
||||
const authAppsBySlug = useMemo(() => new Map((authApps ?? []).map((app) => [app.slug, app])), [authApps]);
|
||||
const launcherServices = useMemo(
|
||||
() => {
|
||||
const services = buildLauncherServices(data, activeProfileId, resolvedClientId);
|
||||
const services = buildLauncherServices(data, resolvedProfileId, resolvedClientId);
|
||||
|
||||
if (!authSession?.authenticated || authApps === null) {
|
||||
return services;
|
||||
return [];
|
||||
}
|
||||
|
||||
return services.map((service) => {
|
||||
@@ -167,32 +171,34 @@ export function LauncherApp() {
|
||||
effectiveAccess: {
|
||||
...service.effectiveAccess,
|
||||
allowed: false,
|
||||
visible: true,
|
||||
visible: false,
|
||||
openEnabled: false,
|
||||
reason: "Нет доступа",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const openEnabled = app.hasAccess && app.status === "active";
|
||||
const appVisible = app.hasAccess && app.status !== "hidden" && app.status !== "disabled";
|
||||
const allowed = appVisible && service.effectiveAccess.allowed;
|
||||
const openEnabled = appVisible && app.status === "active" && service.effectiveAccess.openEnabled;
|
||||
|
||||
return {
|
||||
...service,
|
||||
title: app.title || service.title,
|
||||
description: app.description || service.description,
|
||||
openUrl: openEnabled ? app.openUrl || app.url || service.openUrl : null,
|
||||
userAccess: openEnabled ? ("allowed" as const) : ("denied" as const),
|
||||
userAccess: allowed ? ("allowed" as const) : ("denied" as const),
|
||||
effectiveAccess: {
|
||||
...service.effectiveAccess,
|
||||
allowed: app.hasAccess,
|
||||
visible: true,
|
||||
allowed,
|
||||
visible: appVisible && service.effectiveAccess.visible,
|
||||
openEnabled,
|
||||
reason: app.accessReason || (app.hasAccess ? "Доступ подтверждён" : "Нет доступа"),
|
||||
reason: !app.hasAccess ? app.accessReason || "Нет доступа" : service.effectiveAccess.reason,
|
||||
},
|
||||
};
|
||||
});
|
||||
}).filter((service) => service.effectiveAccess.visible);
|
||||
},
|
||||
[authApps, authAppsBySlug, authSession, data, activeProfileId, resolvedClientId]
|
||||
[authApps, authAppsBySlug, authSession, data, resolvedProfileId, resolvedClientId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -846,7 +852,7 @@ export function LauncherApp() {
|
||||
me={runtimeMe}
|
||||
clients={data.clients}
|
||||
profileOptions={profileOptions}
|
||||
activeProfileId={activeProfileId}
|
||||
activeProfileId={resolvedProfileId}
|
||||
activeClientId={resolvedClientId}
|
||||
adminOpen={adminOpen}
|
||||
adminMode={runtimeMe.launcherRole === "root_admin" ? adminMode : "admin"}
|
||||
@@ -1191,6 +1197,28 @@ function resolveAuthenticatedContext(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRuntimeProfileId(data: LauncherData, session: AuthSession | null, currentProfileId: string): string {
|
||||
if (data.users.some((user) => user.id === currentProfileId)) {
|
||||
return currentProfileId;
|
||||
}
|
||||
|
||||
if (session?.authenticated) {
|
||||
const sessionEmail = session.user.email?.toLowerCase();
|
||||
const sessionSub = session.user.sub;
|
||||
const sessionUser = data.users.find(
|
||||
(user) =>
|
||||
(sessionSub && user.authentikUserId === sessionSub) ||
|
||||
(sessionEmail && user.email.toLowerCase() === sessionEmail)
|
||||
);
|
||||
|
||||
if (sessionUser) {
|
||||
return sessionUser.id;
|
||||
}
|
||||
}
|
||||
|
||||
return data.users[0]?.id ?? currentProfileId;
|
||||
}
|
||||
|
||||
function resolveDefaultClientId(data: LauncherData, userId: string, requestedClientId: string): string {
|
||||
const user = data.users.find((item) => item.id === userId);
|
||||
const isRoot = user?.id === "user_root";
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface LauncherAuthApp {
|
||||
export async function fetchAuthSession(): Promise<AuthSession> {
|
||||
const response = await fetch("/api/me", { cache: "no-store" });
|
||||
|
||||
if (response.status === 401) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return (await response.json()) as UnauthenticatedSession;
|
||||
}
|
||||
|
||||
|
||||
@@ -261,7 +261,9 @@ export function buildMe(data: LauncherData, userId: string, requestedClientId?:
|
||||
}));
|
||||
|
||||
const fallbackClientId =
|
||||
profileOptions.find((option) => option.userId === user.id)?.defaultClientId ?? availableMemberships[0]?.clientId;
|
||||
profileOptions.find((option) => option.userId === user.id)?.defaultClientId ??
|
||||
availableMemberships[0]?.clientId ??
|
||||
PUBLIC_POOL_CLIENT.id;
|
||||
const canUseRequestedClient = availableMemberships.some((membership) => membership.clientId === requestedClientId);
|
||||
const activeClientId = canUseRequestedClient ? requestedClientId! : fallbackClientId;
|
||||
const activeMembership = availableMemberships.find((membership) => membership.clientId === activeClientId);
|
||||
|
||||
@@ -30,11 +30,13 @@ export async function uploadStorageFile(file: File): Promise<StoredFileResponse>
|
||||
|
||||
export async function loadPersistedLauncherData(): Promise<LauncherData | null> {
|
||||
try {
|
||||
const response = await fetch(`/storage/launcher-data.json?ts=${Date.now()}`, { cache: "no-store" });
|
||||
const response = await fetch("/api/storage/data", { cache: "no-store" });
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
return normalizeLauncherData((await response.json()) as Partial<LauncherData>);
|
||||
const data = normalizeLauncherData((await response.json()) as Partial<LauncherData>);
|
||||
|
||||
return data.users.length > 0 ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { KeyRound, Save, Upload, X } from "lucide-react";
|
||||
import type { LauncherUser } from "../../entities/user/types";
|
||||
import { uploadStorageFile } from "../../shared/api/storageApi";
|
||||
@@ -17,13 +17,42 @@ export function ProfileSettingsPanel({
|
||||
onChangePassword: (newPassword: string) => Promise<void>;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<LauncherUser>(user);
|
||||
const [avatarPreviewUrl, setAvatarPreviewUrl] = useState<string | null>(user.avatarUrl ?? null);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [savingPassword, setSavingPassword] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const draftRef = useRef(draft);
|
||||
const hasUnsavedProfileChangesRef = useRef(false);
|
||||
const lastSyncedUserIdRef = useRef(user.id);
|
||||
|
||||
useEffect(() => setDraft(user), [user]);
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
useEffect(() => {
|
||||
const isAnotherUser = lastSyncedUserIdRef.current !== user.id;
|
||||
|
||||
if (!isAnotherUser && hasUnsavedProfileChangesRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSyncedUserIdRef.current = user.id;
|
||||
hasUnsavedProfileChangesRef.current = false;
|
||||
setDraft(user);
|
||||
setAvatarPreviewUrl(user.avatarUrl ?? null);
|
||||
setUploading(false);
|
||||
}, [user]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (avatarPreviewUrl?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(avatarPreviewUrl);
|
||||
}
|
||||
},
|
||||
[avatarPreviewUrl]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -35,12 +64,20 @@ export function ProfileSettingsPanel({
|
||||
}, [onClose]);
|
||||
|
||||
function update<K extends keyof LauncherUser>(key: K, value: LauncherUser[K]) {
|
||||
setDraft((current) => ({ ...current, [key]: value }));
|
||||
hasUnsavedProfileChangesRef.current = true;
|
||||
setDraft((current) => {
|
||||
const nextDraft = { ...current, [key]: value };
|
||||
draftRef.current = nextDraft;
|
||||
return nextDraft;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAvatarUpload(file: File | undefined) {
|
||||
if (!file) return;
|
||||
|
||||
const localPreviewUrl = URL.createObjectURL(file);
|
||||
hasUnsavedProfileChangesRef.current = true;
|
||||
setAvatarPreviewUrl(localPreviewUrl);
|
||||
setUploading(true);
|
||||
setMessage(null);
|
||||
|
||||
@@ -49,6 +86,7 @@ export function ProfileSettingsPanel({
|
||||
update("avatarUrl", result.url);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Не удалось загрузить аватар");
|
||||
setAvatarPreviewUrl(draftRef.current.avatarUrl ?? null);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -59,13 +97,16 @@ export function ProfileSettingsPanel({
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const profileDraft = draftRef.current;
|
||||
await onSaveProfile({
|
||||
name: draft.name,
|
||||
email: draft.email,
|
||||
phone: draft.phone ?? null,
|
||||
position: draft.position ?? null,
|
||||
avatarUrl: draft.avatarUrl ?? null,
|
||||
name: profileDraft.name,
|
||||
email: profileDraft.email,
|
||||
phone: profileDraft.phone ?? null,
|
||||
position: profileDraft.position ?? null,
|
||||
avatarUrl: profileDraft.avatarUrl ?? null,
|
||||
});
|
||||
hasUnsavedProfileChangesRef.current = false;
|
||||
setAvatarPreviewUrl(profileDraft.avatarUrl ?? null);
|
||||
setMessage("Профиль сохранён");
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "Не удалось сохранить профиль");
|
||||
@@ -109,8 +150,8 @@ export function ProfileSettingsPanel({
|
||||
|
||||
<div className="profile-settings-panel__body">
|
||||
<div className="profile-settings-avatar-card">
|
||||
{draft.avatarUrl ? (
|
||||
<img className="profile-settings-avatar-card__image" src={draft.avatarUrl} alt="" />
|
||||
{avatarPreviewUrl ? (
|
||||
<img className="profile-settings-avatar-card__image" src={avatarPreviewUrl} alt="" />
|
||||
) : (
|
||||
<span className="profile-settings-avatar-card__image">{initials(draft.name)}</span>
|
||||
)}
|
||||
@@ -121,7 +162,11 @@ export function ProfileSettingsPanel({
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
disabled={uploading}
|
||||
onChange={(event) => void handleAvatarUpload(event.target.files?.[0])}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
event.currentTarget.value = "";
|
||||
void handleAvatarUpload(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user