mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
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:
@@ -9,6 +9,7 @@ import { env } from "./env.js";
|
||||
import { errorHandler, notFound } from "./middleware/error.js";
|
||||
import { initRealtime } from "./realtime.js";
|
||||
import { activityRouter } from "./routes/activity.js";
|
||||
import { authHelpersRouter } from "./routes/auth-helpers.js";
|
||||
import { aiRouter } from "./routes/ai.js";
|
||||
import { analyticsRouter } from "./routes/analytics.js";
|
||||
import { attachmentsRouter } from "./routes/attachments.js";
|
||||
@@ -86,6 +87,7 @@ app.use("/api/ai", aiRouter);
|
||||
app.use("/api/chat", chatRouter);
|
||||
app.use("/api/integrations", integrationsRouter);
|
||||
app.use("/api/portal", portalRouter);
|
||||
app.use("/api/auth-helpers", authHelpersRouter);
|
||||
|
||||
app.use(notFound);
|
||||
app.use(errorHandler);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "../auth.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { user } from "../db/schema/auth.js";
|
||||
|
||||
// Public auth helpers that sit alongside Better Auth's own /api/auth handler.
|
||||
//
|
||||
// Better Auth's password-reset endpoint is keyed by email, but staff
|
||||
// provisioned by an admin sign in with a *username* (and may only have a
|
||||
// synthetic `username@slug.temetro.local` address). This lets them start a reset
|
||||
// by username: we resolve the username to its account, then hand off to the
|
||||
// normal reset flow (which emails a link if a provider is configured, or alerts
|
||||
// the clinic admins otherwise — see src/auth.ts sendResetPassword).
|
||||
export const authHelpersRouter = Router();
|
||||
|
||||
const resetByUsernameSchema = z.object({
|
||||
username: z.string().trim().min(1).max(64),
|
||||
redirectTo: z.string().trim().max(2048).optional(),
|
||||
});
|
||||
|
||||
// POST /api/auth-helpers/reset-by-username
|
||||
// Always responds 200 with a generic body — never reveals whether the username
|
||||
// exists (avoids account enumeration) and never echoes the resolved email.
|
||||
authHelpersRouter.post("/reset-by-username", async (req, res, next) => {
|
||||
try {
|
||||
const { username, redirectTo } = resetByUsernameSchema.parse(req.body);
|
||||
|
||||
const [account] = await db
|
||||
.select({ email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.username, username.toLowerCase()))
|
||||
.limit(1);
|
||||
|
||||
if (account?.email) {
|
||||
// Reuse Better Auth's reset flow so the same dispatch/fallback logic runs.
|
||||
await auth.api.requestPasswordReset({
|
||||
body: { email: account.email, redirectTo },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -145,10 +145,14 @@ async function buildSummaries(
|
||||
: (others[0]?.name ?? "Conversation"));
|
||||
const lastMessage = lastByConv.get(b.convId) ?? null;
|
||||
const unreadCount = unreadByConv.get(b.convId) ?? 0;
|
||||
// A one-way System notice (e.g. forgot-password alerts): the reserved system
|
||||
// user is a participant. The UI hides the composer/call and styles it apart.
|
||||
const isSystem = participants.some((p) => p.id === SYSTEM_USER_ID);
|
||||
return {
|
||||
id: b.convId,
|
||||
name: displayName,
|
||||
isGroup: b.isGroup,
|
||||
isSystem,
|
||||
participants,
|
||||
lastMessage,
|
||||
unread: unreadCount > 0,
|
||||
|
||||
@@ -50,6 +50,7 @@ export type ConversationSummary = {
|
||||
id: string;
|
||||
name: string; // display name: group name, or the other participant for a DM
|
||||
isGroup: boolean;
|
||||
isSystem: boolean; // a one-way System notice (no replies / calls)
|
||||
participants: Participant[];
|
||||
lastMessage: ConversationMessage | null;
|
||||
unread: boolean;
|
||||
|
||||
@@ -7,12 +7,19 @@ import { useTranslation } from "react-i18next";
|
||||
import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTab } from "@/components/ui/tabs";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { requestResetByUsername } from "@/lib/auth-helpers";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
type Mode = "email" | "username";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState("");
|
||||
// Owners reset by the email they signed up with; admin-provisioned staff reset
|
||||
// by their username (their email may be a synthetic placeholder).
|
||||
const [mode, setMode] = useState<Mode>("email");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -23,18 +30,33 @@ export default function ForgotPasswordPage() {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const { error: err } = await authClient.requestPasswordReset({
|
||||
email: email.trim(),
|
||||
redirectTo: `${window.location.origin}/reset-password`,
|
||||
});
|
||||
const redirectTo = `${window.location.origin}/reset-password`;
|
||||
|
||||
setSubmitting(false);
|
||||
if (err) {
|
||||
const message = err.message ?? t("auth.forgotPassword.error");
|
||||
setError(message);
|
||||
notify.error(t("auth.forgotPassword.failedToastTitle"), message);
|
||||
return;
|
||||
if (mode === "email") {
|
||||
const { error: err } = await authClient.requestPasswordReset({
|
||||
email: identifier.trim(),
|
||||
redirectTo,
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (err) {
|
||||
const message = err.message ?? t("auth.forgotPassword.error");
|
||||
setError(message);
|
||||
notify.error(t("auth.forgotPassword.failedToastTitle"), message);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await requestResetByUsername(identifier.trim(), redirectTo);
|
||||
} catch {
|
||||
setSubmitting(false);
|
||||
const message = t("auth.forgotPassword.error");
|
||||
setError(message);
|
||||
notify.error(t("auth.forgotPassword.failedToastTitle"), message);
|
||||
return;
|
||||
}
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
notify.success(
|
||||
t("auth.forgotPassword.sentToastTitle"),
|
||||
t("auth.forgotPassword.sentToastBody"),
|
||||
@@ -54,20 +76,50 @@ export default function ForgotPasswordPage() {
|
||||
>
|
||||
{sent ? (
|
||||
<FormAlert tone="success">
|
||||
{t("auth.forgotPassword.sent", { email })}
|
||||
{mode === "email"
|
||||
? t("auth.forgotPassword.sent", { email: identifier })
|
||||
: t("auth.forgotPassword.sentUsername")}
|
||||
</FormAlert>
|
||||
) : (
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Field htmlFor="email" label={t("auth.forgotPassword.emailLabel")}>
|
||||
<Tabs
|
||||
onValueChange={(value) => {
|
||||
setMode(value as Mode);
|
||||
setError(null);
|
||||
setIdentifier("");
|
||||
}}
|
||||
value={mode}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTab className="flex-1" value="email">
|
||||
{t("auth.forgotPassword.modeEmail")}
|
||||
</TabsTab>
|
||||
<TabsTab className="flex-1" value="username">
|
||||
{t("auth.forgotPassword.modeUsername")}
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Field
|
||||
htmlFor="identifier"
|
||||
label={
|
||||
mode === "email"
|
||||
? t("auth.forgotPassword.emailLabel")
|
||||
: t("auth.forgotPassword.usernameLabel")
|
||||
}
|
||||
>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("auth.forgotPassword.emailPlaceholder")}
|
||||
autoComplete={mode === "email" ? "email" : "username"}
|
||||
id="identifier"
|
||||
onChange={(e) => setIdentifier(e.target.value)}
|
||||
placeholder={
|
||||
mode === "email"
|
||||
? t("auth.forgotPassword.emailPlaceholder")
|
||||
: t("auth.forgotPassword.usernamePlaceholder")
|
||||
}
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
type={mode === "email" ? "email" : "text"}
|
||||
value={identifier}
|
||||
/>
|
||||
</Field>
|
||||
<Button className="mt-1 w-full" disabled={submitting} type="submit">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { apiFetch } from "@/lib/api-client";
|
||||
|
||||
// Start a password reset by username (for staff who sign in with a username
|
||||
// rather than an email). The backend resolves the username to its account and
|
||||
// hands off to Better Auth's reset flow. Always resolves; never reveals whether
|
||||
// the username exists.
|
||||
export async function requestResetByUsername(
|
||||
username: string,
|
||||
redirectTo: string,
|
||||
): Promise<void> {
|
||||
await apiFetch("/api/auth-helpers/reset-by-username", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, redirectTo }),
|
||||
});
|
||||
}
|
||||
@@ -61,12 +61,17 @@
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Reset your password",
|
||||
"subtitle": "We'll email you a link to reset your password",
|
||||
"subtitle": "We'll send a link to reset your password",
|
||||
"modeEmail": "Email",
|
||||
"modeUsername": "Username",
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "you@clinic.org",
|
||||
"usernameLabel": "Username",
|
||||
"usernamePlaceholder": "your username",
|
||||
"submit": "Send reset link",
|
||||
"submitting": "Sending…",
|
||||
"sent": "If an account exists for {{email}}, a reset link is on its way. Check your inbox.",
|
||||
"sentUsername": "If that username exists, a reset link is on its way. If you don't have an inbox, your clinic admin has been notified to set a new password for you.",
|
||||
"error": "Could not send the reset email.",
|
||||
"failedToastTitle": "Couldn't send reset link",
|
||||
"sentToastTitle": "Reset link sent",
|
||||
@@ -732,9 +737,16 @@
|
||||
"cancel": "Cancel",
|
||||
"createFailedTitle": "Couldn't create room",
|
||||
"createFailedBody": "Please try again.",
|
||||
"deleteRoom": "Delete room",
|
||||
"deleting": "Deleting…",
|
||||
"deleteRoomConfirmTitle": "Delete this room?",
|
||||
"deleteRoomConfirmBody": "“{{name}}” will be removed for everyone. Anyone currently in the call will be disconnected.",
|
||||
"deleteFailedTitle": "Couldn't delete room",
|
||||
"deleteFailedBody": "Please try again.",
|
||||
"emptyTitle": "No room selected",
|
||||
"emptyDescription": "Pick a room to join the call, or create a new one.",
|
||||
"you": "You",
|
||||
"guest": "Guest",
|
||||
"inCall": "{{count}} in call",
|
||||
"connecting": "Connecting…",
|
||||
"roomFull": "Room is full (max {{max}}).",
|
||||
@@ -834,6 +846,8 @@
|
||||
"startCall": "Start a call with {{name}}",
|
||||
"system": {
|
||||
"label": "System message",
|
||||
"tag": "System",
|
||||
"readOnly": "This is an automated system notice — you can't reply here.",
|
||||
"passwordResetTitle": "Password reset requested",
|
||||
"passwordResetBody": "{{name}} forgot their password. Tap to open their settings and set a new one."
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export type ConversationSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
isGroup: boolean;
|
||||
isSystem: boolean;
|
||||
participants: Participant[];
|
||||
lastMessage: ConversationMessage | null;
|
||||
unread: boolean;
|
||||
|
||||
Reference in New Issue
Block a user