Add full profiles and latency tests

This commit is contained in:
cesnimda
2026-03-22 12:06:25 +01:00
parent 91f6361055
commit 0fa481cab6
11 changed files with 704 additions and 103 deletions
+63 -38
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { Avatar, Box, Button, Paper, TextField, Typography } from "@mui/material";
import { Alert, Avatar, Box, Button, Paper, TextField, Typography } from "@mui/material";
import { api } from "../api";
import { useToast } from "../toast";
@@ -10,15 +10,26 @@ type MeResponse = {
id?: string;
email?: string;
userName?: string;
firstName?: string;
lastName?: string;
displayName?: string;
roles?: string[];
googleLink?: {
linked: boolean;
email?: string | null;
linkedAt?: string | null;
} | null;
};
function initialsFrom(s?: string) {
const v = (s ?? "").trim();
if (!v) return "?";
const parts = v.split(/[\s@._-]+/).filter(Boolean);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
function initialsFrom(values: Array<string | undefined>) {
const joined = values.map((x) => (x ?? "").trim()).filter(Boolean);
if (joined.length === 0) return "?";
if (joined.length === 1) {
const parts = joined[0].split(/[\s@._-]+/).filter(Boolean);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return (joined[0][0] + joined[1][0]).toUpperCase();
}
export default function ProfilePage() {
@@ -28,62 +39,76 @@ export default function ProfilePage() {
const [email, setEmail] = useState("");
const [userName, setUserName] = useState("");
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [displayName, setDisplayName] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
async function loadProfile() {
try {
const r = await api.get<MeResponse>("/auth/me");
setMe(r.data);
setEmail(r.data?.email ?? "");
setUserName(r.data?.userName ?? "");
setFirstName(r.data?.firstName ?? "");
setLastName(r.data?.lastName ?? "");
setDisplayName(r.data?.displayName ?? "");
} catch {
setMe(null);
}
}
useEffect(() => {
api
.get<MeResponse>("/auth/me")
.then((r) => {
setMe(r.data);
setEmail(r.data?.email ?? "");
setUserName(r.data?.userName ?? "");
})
.catch(() => setMe(null));
void loadProfile();
}, []);
const initials = useMemo(() => initialsFrom(me?.userName || me?.email), [me]);
const initials = useMemo(
() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]),
[me],
);
const isLocal = me?.provider === "local";
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
return (
<Paper sx={{ mt: 0, p: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
<Avatar sx={{ width: 44, height: 44, fontWeight: 900 }}>{initials}</Avatar>
<Avatar sx={{ width: 52, height: 52, fontWeight: 900 }}>{initials}</Avatar>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>
Profile
</Typography>
<Typography sx={{ color: "text.secondary" }}>
{me?.email ? me.email : "—"} {me?.provider ? `(${me.provider})` : ""}
{me?.displayName || fullName || me?.userName || me?.email || "-"}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{me?.email || "-"} {me?.provider ? `(${me.provider})` : ""}
</Typography>
</Box>
</Box>
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="h6">Account</Typography>
{!isLocal ? (
<Typography sx={{ color: "text.secondary" }}>
This account is authenticated via Google; profile updates are read-only in this build.
</Typography>
<Alert severity="info" sx={{ mt: 1 }}>
This session is not using a local app token, so profile edits are read-only right now.
</Alert>
) : null}
</Box>
<TextField
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={!isLocal}
fullWidth
/>
<TextField
label="Username"
value={userName}
onChange={(e) => setUserName(e.target.value)}
disabled={!isLocal}
fullWidth
/>
<TextField label="Display name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="Username" value={userName} onChange={(e) => setUserName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="First name" value={firstName} onChange={(e) => setFirstName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="Last name" value={lastName} onChange={(e) => setLastName(e.target.value)} disabled={!isLocal} fullWidth />
<TextField label="Email" value={email} onChange={(e) => setEmail(e.target.value)} disabled={!isLocal} fullWidth sx={{ gridColumn: "1 / -1" }} />
<Box sx={{ gridColumn: "1 / -1" }}>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Google account: {me?.googleLink?.linked ? `Linked${me.googleLink.email ? ` to ${me.googleLink.email}` : ""}` : "Not linked"}
</Typography>
</Box>
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
<Button
@@ -92,7 +117,8 @@ export default function ProfilePage() {
onClick={async () => {
setLoading(true);
try {
await api.put("/auth/profile", { email, userName });
await api.put("/auth/profile", { email, userName, firstName, lastName, displayName });
await loadProfile();
toast("Profile updated.", "success");
} catch (e: any) {
const msg = e?.response?.data || e?.message || "Failed to update profile.";
@@ -161,4 +187,3 @@ export default function ProfilePage() {
</Paper>
);
}