feat: username reset, system-message UX, meetings polish, sidebar identity

- auth: forgot-password now has Email/Username tabs; new public
  /api/auth-helpers/reset-by-username resolves a username and hands off to the
  existing reset flow (real email or admin-notify fallback)
- messages: conversations expose isSystem; System notices are read-only (no
  composer, no call button) and styled distinctly (shield icon + badge)
- meetings: compact, larger control bar; self tile shows real initials and an
  avatar (not black) when the camera is off; delete-room UI with confirm
- sidebar: username-only accounts show @username instead of a synthetic email

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-20 22:39:48 +03:00
parent 90e6ec4cc0
commit b74d5d2d05
12 changed files with 343 additions and 97 deletions
+44 -39
View File
@@ -48,11 +48,15 @@ function initials(name: string): string {
function VideoTile({
stream,
label,
caption,
muted,
showVideo,
}: {
stream: MediaStream | null;
label: string;
// The corner caption (e.g. "You"); falls back to `label` when omitted. Initials
// are always derived from `label` (the real name), never the caption.
caption?: string;
muted?: boolean;
showVideo: boolean;
}) {
@@ -81,14 +85,16 @@ function VideoTile({
ref={ref}
/>
{!showVideo && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="absolute inset-0 flex items-center justify-center bg-secondary">
<Avatar className="size-16">
<AvatarFallback className="text-lg">{initials(label)}</AvatarFallback>
<AvatarFallback className="bg-primary/15 text-foreground text-lg">
{initials(label)}
</AvatarFallback>
</Avatar>
</div>
)}
<span className="absolute bottom-2 left-2 rounded-full bg-background/70 px-2 py-0.5 text-foreground text-xs backdrop-blur">
{label}
{caption ?? label}
</span>
</div>
);
@@ -114,7 +120,7 @@ function ControlButton({
render={
<Button
aria-label={label}
className="size-12 rounded-full"
className="size-14 rounded-full"
onClick={onClick}
size="icon"
variant={active === false ? "outline" : variant}
@@ -211,7 +217,8 @@ export function MeetingRoom({
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<VideoTile
label={t("meetings.you")}
caption={t("meetings.you")}
label={selfName || t("meetings.you")}
muted
showVideo={camOn && Boolean(localStream)}
stream={localStream}
@@ -219,7 +226,7 @@ export function MeetingRoom({
{peers.map((p) => (
<VideoTile
key={p.socketId}
label={p.userName || selfName}
label={p.userName || t("meetings.guest")}
showVideo={Boolean(p.stream)}
stream={p.stream}
/>
@@ -228,45 +235,43 @@ export function MeetingRoom({
)}
</div>
{/* Discord-style control bar */}
<div className="flex items-center justify-center gap-3 rounded-full border bg-card/60 px-4 py-2 backdrop-blur">
<div className="flex items-center gap-2">
<ControlButton
active={micOn}
label={micOn ? t("meetings.muteMic") : t("meetings.unmuteMic")}
onClick={toggleMic}
>
{micOn ? <Mic className="size-5" /> : <MicOff className="size-5" />}
</ControlButton>
<ControlButton
active={camOn}
label={camOn ? t("meetings.stopVideo") : t("meetings.startVideo")}
onClick={toggleCam}
>
{camOn ? (
<VideoIcon className="size-5" />
) : (
<VideoOff className="size-5" />
)}
</ControlButton>
<ControlButton
label={t("meetings.shareScreen")}
onClick={() => void toggleScreen()}
variant={screenOn ? "default" : "secondary"}
>
<MonitorUp className="size-5" />
</ControlButton>
<ControlButton label={t("meetings.invite.add")} onClick={openInvite}>
<UserPlus className="size-5" />
</ControlButton>
</div>
{/* Discord-style control bar — a compact pill that hugs its buttons */}
<div className="mx-auto flex w-fit items-center gap-2 rounded-full border bg-card/60 px-3 py-2 backdrop-blur">
<ControlButton
active={micOn}
label={micOn ? t("meetings.muteMic") : t("meetings.unmuteMic")}
onClick={toggleMic}
>
{micOn ? <Mic className="size-6" /> : <MicOff className="size-6" />}
</ControlButton>
<ControlButton
active={camOn}
label={camOn ? t("meetings.stopVideo") : t("meetings.startVideo")}
onClick={toggleCam}
>
{camOn ? (
<VideoIcon className="size-6" />
) : (
<VideoOff className="size-6" />
)}
</ControlButton>
<ControlButton
label={t("meetings.shareScreen")}
onClick={() => void toggleScreen()}
variant={screenOn ? "default" : "secondary"}
>
<MonitorUp className="size-6" />
</ControlButton>
<ControlButton label={t("meetings.invite.add")} onClick={openInvite}>
<UserPlus className="size-6" />
</ControlButton>
<div className="mx-1 h-8 w-px bg-border" />
<ControlButton
label={t("meetings.leave")}
onClick={onLeave}
variant="destructive"
>
<PhoneOff className="size-5" />
<PhoneOff className="size-6" />
</ControlButton>
</div>
+74 -15
View File
@@ -1,6 +1,6 @@
"use client";
import { CalendarDays, Plus, Users, Video } from "lucide-react";
import { CalendarDays, Plus, Trash2, Users, Video } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { type FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -32,6 +32,7 @@ import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import {
createMeetingRoom,
deleteMeetingRoom,
listMeetingEvents,
listMeetingRooms,
type MeetingRoom as Room,
@@ -70,6 +71,8 @@ export function MeetingsView() {
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false);
const [roomToDelete, setRoomToDelete] = useState<Room | null>(null);
const [deleting, setDeleting] = useState(false);
// Scheduled meetings (calendar).
const [events, setEvents] = useState<ScheduledMeeting[]>([]);
@@ -138,6 +141,21 @@ export function MeetingsView() {
}
};
const confirmDeleteRoom = async () => {
if (!roomToDelete || deleting) return;
setDeleting(true);
try {
await deleteMeetingRoom(roomToDelete.id);
setRooms((prev) => prev.filter((r) => r.id !== roomToDelete.id));
if (activeRoom?.id === roomToDelete.id) setActiveRoom(null);
setRoomToDelete(null);
} catch {
notify.error(t("meetings.deleteFailedTitle"), t("meetings.deleteFailedBody"));
} finally {
setDeleting(false);
}
};
const meetingDays = useMemo(
() => events.map((e) => new Date(`${e.date}T00:00:00`)),
[events],
@@ -226,26 +244,38 @@ export function MeetingsView() {
rooms.map((room) => {
const count = presence[room.id] ?? 0;
return (
<button
<div
className={cn(
"flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent/50",
"group flex w-full items-center gap-1 rounded-lg pr-1 transition-colors hover:bg-accent/50",
activeRoom?.id === room.id && "bg-accent hover:bg-accent",
)}
key={room.id}
onClick={() => setActiveRoom(room)}
type="button"
>
<Video className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
{room.name}
</span>
{count > 0 && (
<span className="flex items-center gap-1 rounded-full bg-success/15 px-1.5 text-success text-xs">
<Users className="size-3" />
{count}
<button
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2 py-2 text-left"
onClick={() => setActiveRoom(room)}
type="button"
>
<Video className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-foreground text-sm">
{room.name}
</span>
)}
</button>
{count > 0 && (
<span className="flex items-center gap-1 rounded-full bg-success/15 px-1.5 text-success text-xs">
<Users className="size-3" />
{count}
</span>
)}
</button>
<button
aria-label={t("meetings.deleteRoom")}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
onClick={() => setRoomToDelete(room)}
type="button"
>
<Trash2 className="size-3.5" />
</button>
</div>
);
})
)}
@@ -435,6 +465,35 @@ export function MeetingsView() {
</DialogPopup>
</Dialog>
<Dialog
onOpenChange={(open) => !open && setRoomToDelete(null)}
open={roomToDelete !== null}
>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("meetings.deleteRoomConfirmTitle")}</DialogTitle>
<DialogDescription>
{t("meetings.deleteRoomConfirmBody", {
name: roomToDelete?.name ?? "",
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
{t("meetings.cancel")}
</DialogClose>
<Button
disabled={deleting}
onClick={confirmDeleteRoom}
type="button"
variant="destructive"
>
{deleting ? t("meetings.deleting") : t("meetings.deleteRoom")}
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
<ScheduleMeetingDialog
defaultDate={keyOf(selectedDay)}
defaultParticipants={deepLinkWith ? [deepLinkWith] : undefined}
+60 -21
View File
@@ -10,6 +10,7 @@ import {
Plus,
Search,
SendHorizonal,
ShieldAlert,
Video,
X,
} from "lucide-react";
@@ -495,27 +496,41 @@ export function MessagesView() {
)}
key={c.id}
>
<button
aria-label={t("messages.startCall", { name: c.name })}
className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
onClick={() =>
router.push(
otherId
? `/messages/meetings?with=${encodeURIComponent(otherId)}`
: "/messages/meetings",
)
}
type="button"
>
<Video className="size-4" />
</button>
{c.isSystem ? (
<span className="size-9 shrink-0" />
) : (
<button
aria-label={t("messages.startCall", { name: c.name })}
className="flex size-9 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-primary/10 hover:text-primary"
onClick={() =>
router.push(
otherId
? `/messages/meetings?with=${encodeURIComponent(otherId)}`
: "/messages/meetings",
)
}
type="button"
>
<Video className="size-4" />
</button>
)}
<button
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2 pr-1 text-left"
onClick={() => open(c.id)}
type="button"
>
<Avatar className="size-9 shrink-0">
<AvatarFallback>{initials(c.name)}</AvatarFallback>
<AvatarFallback
className={cn(
c.isSystem && "bg-warning/15 text-warning",
)}
>
{c.isSystem ? (
<ShieldAlert className="size-4" />
) : (
initials(c.name)
)}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex w-full items-center gap-2">
@@ -529,6 +544,11 @@ export function MessagesView() {
>
{c.name}
</span>
{c.isSystem && (
<span className="shrink-0 rounded-full bg-warning/15 px-1.5 py-0.5 font-medium text-[10px] text-warning uppercase tracking-wide">
{t("messages.system.tag")}
</span>
)}
<span
className={cn(
"shrink-0 text-xs",
@@ -573,18 +593,30 @@ export function MessagesView() {
<div className="flex h-full flex-col gap-4">
<div className="flex items-center gap-3 rounded-2xl border bg-card/30 p-4">
<Avatar className="size-9">
<AvatarFallback>{initials(selected.name)}</AvatarFallback>
<AvatarFallback
className={cn(
selected.isSystem && "bg-warning/15 text-warning",
)}
>
{selected.isSystem ? (
<ShieldAlert className="size-4" />
) : (
initials(selected.name)
)}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{selected.name}
</span>
<span className="text-muted-foreground text-xs">
{selected.isGroup
? t("messages.peopleCount", {
count: selected.participants.length,
})
: t("messages.directMessage")}
{selected.isSystem
? t("messages.system.label")
: selected.isGroup
? t("messages.peopleCount", {
count: selected.participants.length,
})
: t("messages.directMessage")}
</span>
</div>
</div>
@@ -675,6 +707,12 @@ export function MessagesView() {
</div>
</div>
{selected.isSystem ? (
<div className="flex items-center justify-center gap-2 rounded-3xl border border-input bg-muted/40 px-4 py-3 text-center text-muted-foreground text-sm">
<ShieldAlert className="size-4 shrink-0" />
{t("messages.system.readOnly")}
</div>
) : (
<form
className="flex flex-col rounded-3xl border border-input bg-background shadow-sm transition-colors focus-within:border-ring/60 focus-within:ring-2 focus-within:ring-ring/20"
onSubmit={send}
@@ -776,6 +814,7 @@ export function MessagesView() {
</Button>
</div>
</form>
)}
</div>
) : (
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
+8 -2
View File
@@ -101,6 +101,12 @@ export function NavUser() {
const name = data?.user?.name ?? t("userMenu.defaultName");
const email = data?.user?.email ?? "";
// Admin-provisioned staff sign in by username and may only have a synthetic
// `username@slug.temetro.local` address — show their @username instead of an
// email that looks broken.
const username = data?.user?.displayUsername ?? data?.user?.username ?? null;
const isSyntheticEmail = /@.+\.temetro\.local$/i.test(email);
const secondary = !email || isSyntheticEmail ? (username ? `@${username}` : email) : email;
const initials = initialsFromName(name);
const activeName = activeOrg?.name ?? t("userMenu.selectClinic");
@@ -143,7 +149,7 @@ export function NavUser() {
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{name}</span>
<span className="truncate text-muted-foreground text-xs">
{email}
{secondary}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
@@ -164,7 +170,7 @@ export function NavUser() {
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{name}</span>
<span className="truncate text-muted-foreground text-xs">
{email}
{secondary}
</span>
</div>
</MenuGroupLabel>