diff --git a/bun.lockb b/bun.lockb index 8a51b8a..dab3146 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/src/routes/admin.users.tsx b/src/routes/admin.users.tsx index e866344..68fb4ef 100644 --- a/src/routes/admin.users.tsx +++ b/src/routes/admin.users.tsx @@ -50,7 +50,7 @@ import { TableRow, } from "@/components/ui/table"; import { Badge } from "@/components/ui/badge"; -import { Loader2, UserPlus, Trash2 } from "lucide-react"; +import { Loader2, UserPlus, Trash2, KeyRound } from "lucide-react"; import { toast } from "sonner"; export const Route = createFileRoute("/admin/users")({ @@ -189,7 +189,7 @@ function UsersPage() { Email Role Hourly rate - + Actions @@ -231,29 +231,32 @@ function UsersPage() { /> - {!isSelf && ( - - - - - - - Delete user? - - This will permanently remove {u.email} and revoke their access. - - - - Cancel - handleDelete(u.id)}> - Delete - - - - - )} +
+ + {!isSelf && ( + + + + + + + Delete user? + + This will permanently remove {u.email} and revoke their access. + + + + Cancel + handleDelete(u.id)}> + Delete + + + + + )} +
); @@ -312,6 +315,69 @@ function RateInput({ } +function SetPasswordDialog({ userId, email }: { userId: string; email: string }) { + const [open, setOpen] = useState(false); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitting(true); + const { error } = await supabase.functions.invoke("admin-set-password", { + body: { user_id: userId, password }, + }); + setSubmitting(false); + if (error) { + toast.error("Could not change password", { description: error.message }); + return; + } + toast.success("Password updated", { + description: "Share the new password securely with the user.", + }); + setPassword(""); + setOpen(false); + }; + + return ( + + + + + + + Change password + + Set a new password for {email}. Share it with them through a secure channel. + + +
+
+ + setPassword(e.target.value)} + minLength={10} + required + autoFocus + /> +

Min 10 characters.

+
+ + + +
+
+
+ ); +} + function InviteDialog({ onCreated }: { onCreated: () => void }) { const [email, setEmail] = useState(""); const [fullName, setFullName] = useState(""); diff --git a/supabase/functions/admin-set-password/index.ts b/supabase/functions/admin-set-password/index.ts new file mode 100644 index 0000000..1edd33d --- /dev/null +++ b/supabase/functions/admin-set-password/index.ts @@ -0,0 +1,71 @@ +// Admin-only edge function to set a user's password. +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.4"; + +const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", +}; + +Deno.serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders }); + + try { + const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; + const SERVICE_ROLE = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; + const ANON_KEY = Deno.env.get("SUPABASE_PUBLISHABLE_KEY") ?? Deno.env.get("SUPABASE_ANON_KEY")!; + + const token = (req.headers.get("Authorization") ?? "").replace("Bearer ", ""); + if (!token) { + return new Response(JSON.stringify({ error: "Missing auth" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const userClient = createClient(SUPABASE_URL, ANON_KEY, { + global: { headers: { Authorization: `Bearer ${token}` } }, + }); + const { data: userData } = await userClient.auth.getUser(); + if (!userData.user) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const admin = createClient(SUPABASE_URL, SERVICE_ROLE); + const { data: isAdminData } = await admin.rpc("is_admin", { _user_id: userData.user.id }); + if (!isAdminData) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + const { user_id, password } = (await req.json()) as { user_id: string; password: string }; + if (!user_id || !password || typeof password !== "string" || password.length < 10) { + return new Response( + JSON.stringify({ error: "Password must be at least 10 characters" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }, + ); + } + + const { error } = await admin.auth.admin.updateUserById(user_id, { password }); + if (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ ok: true }), { + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } catch (e) { + return new Response(JSON.stringify({ error: (e as Error).message }), { + status: 500, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } +});