253 lines · 9.9 KB · TypeScript
RawDownload
import { createFileRoute } from "@tanstack/react-router";import { useState } from "react";import { Exit } from "effect";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import { useAtom, useAtomValue } from "@effect/atom-react";import { toast } from "@executor-js/react/components/sonner"; import { Button } from "@executor-js/react/components/button";import { CopyButton } from "@executor-js/react/components/copy-button";import { Input } from "@executor-js/react/components/input";import { Label } from "@executor-js/react/components/label";import { NativeSelect, NativeSelectOption } from "@executor-js/react/components/native-select";import { useExecutorDocumentTitle } from "@executor-js/react/lib/document-title";import {  orgMembersAtom,  removeMember,  updateMemberRole,} from "@executor-js/react/api/account-atoms";import { orgMemberWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { createInvite, invitesAtom, inviteWriteKeys, revokeInvite } from "../../admin-atoms"; export const Route = createFileRoute("/{-$orgSlug}/admin")({  component: AdminPage,}); const ROLES = ["member", "admin"] as const; // Instance admin console. Members reuse the shared account atoms; invite codes// are the self-host join mechanism. The API gates to owner/admin, so a// non-admin who opens this just sees load failures.function AdminPage() {  useExecutorDocumentTitle("Admin");  return (    <div className="min-h-0 flex-1 overflow-y-auto">      <div className="mx-auto flex max-w-3xl flex-col gap-10 px-6 py-10 lg:px-8 lg:py-14">        <header className="space-y-1">          <h1 className="font-display text-3xl tracking-tight text-foreground">Admin</h1>          <p className="text-sm text-muted-foreground">            Manage members and invite links for this instance.          </p>        </header>        <MembersSection />        <InvitesSection />      </div>    </div>  );} function MembersSection() {  const result = useAtomValue(orgMembersAtom);  const [roleState, doUpdateRole] = useAtom(updateMemberRole, { mode: "promiseExit" });  const [removeState, doRemove] = useAtom(removeMember, { mode: "promiseExit" });  // The mutation atoms carry their own in-flight state — no manual busy tracking.  const busy = AsyncResult.isWaiting(roleState) || AsyncResult.isWaiting(removeState);   const changeRole = async (membershipId: string, roleSlug: string) => {    const exit = await doUpdateRole({      params: { membershipId },      payload: { roleSlug },      reactivityKeys: orgMemberWriteKeys,    });    toast[Exit.isSuccess(exit) ? "success" : "error"](      Exit.isSuccess(exit) ? "Role updated" : "Failed to update role",    );  };   const remove = async (membershipId: string, label: string) => {    const exit = await doRemove({ params: { membershipId }, reactivityKeys: orgMemberWriteKeys });    toast[Exit.isSuccess(exit) ? "success" : "error"](      Exit.isSuccess(exit) ? `Removed ${label}` : "Failed to remove member",    );  };   return (    <section className="space-y-3">      <h2 className="text-sm font-medium text-foreground">Members</h2>      {AsyncResult.match(result, {        onInitial: () => <Notice>Loading members…</Notice>,        onFailure: () => <Notice tone="destructive">Admin access required.</Notice>,        onSuccess: ({ value }) => (          <div className="divide-y divide-border rounded-lg border border-border">            {value.members.map((member) => {              const isOwner = member.role === "owner";              return (                <div key={member.id} className="flex items-center gap-3 p-3">                  <div className="min-w-0 flex-1">                    <p className="truncate text-sm text-foreground">                      {member.name ?? member.email}                      {member.isCurrentUser ? " (you)" : ""}                    </p>                    <p className="truncate text-xs text-muted-foreground">{member.email}</p>                  </div>                  {isOwner || member.isCurrentUser ? (                    <span className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">                      {member.role}                    </span>                  ) : (                    <>                      <NativeSelect                        className="text-xs"                        value={member.role === "admin" ? "admin" : "member"}                        disabled={busy}                        onChange={(e) => changeRole(member.id, e.target.value)}                      >                        {ROLES.map((role) => (                          <NativeSelectOption key={role} value={role}>                            {role}                          </NativeSelectOption>                        ))}                      </NativeSelect>                      <Button                        variant="ghost"                        size="sm"                        disabled={busy}                        onClick={() => remove(member.id, member.name ?? member.email)}                      >                        Remove                      </Button>                    </>                  )}                </div>              );            })}          </div>        ),      })}    </section>  );} function InvitesSection() {  const result = useAtomValue(invitesAtom);  const [createState, doCreate] = useAtom(createInvite, { mode: "promiseExit" });  const [, doRevoke] = useAtom(revokeInvite, { mode: "promiseExit" });  const [role, setRole] = useState<string>("member");  const [label, setLabel] = useState("");  const creating = AsyncResult.isWaiting(createState);   const create = async () => {    const exit = await doCreate({      payload: { role, label: label.trim() || undefined },      reactivityKeys: inviteWriteKeys,    });    if (Exit.isSuccess(exit)) {      setLabel("");      setRole("member");      toast.success("Invite link created");      return;    }    toast.error("Failed to create invite");  };   const revoke = async (inviteId: string) => {    const exit = await doRevoke({ params: { inviteId }, reactivityKeys: inviteWriteKeys });    toast[Exit.isSuccess(exit) ? "success" : "error"](      Exit.isSuccess(exit) ? "Invite revoked" : "Failed to revoke invite",    );  };   return (    <section className="space-y-3">      <h2 className="text-sm font-medium text-foreground">Invite links</h2>      <div className="flex flex-wrap items-end gap-3 rounded-lg border border-border p-3">        <div className="space-y-1.5">          <Label htmlFor="invite-label">Label (optional)</Label>          <Input            id="invite-label"            placeholder="e.g. Alex"            value={label}            onChange={(e) => setLabel(e.target.value)}          />        </div>        <div className="space-y-1.5">          <Label htmlFor="invite-role">Role</Label>          <NativeSelect id="invite-role" value={role} onChange={(e) => setRole(e.target.value)}>            {ROLES.map((r) => (              <NativeSelectOption key={r} value={r}>                {r}              </NativeSelectOption>            ))}          </NativeSelect>        </div>        <Button onClick={create} disabled={creating}>          {creating ? "Creating…" : "Create invite"}        </Button>      </div>       {AsyncResult.match(result, {        onInitial: () => <Notice>Loading invites…</Notice>,        onFailure: () => <Notice tone="destructive">Admin access required.</Notice>,        onSuccess: ({ value }) => {          const pending = value.invites.filter((i) => !i.usedAt);          const used = value.invites.filter((i) => i.usedAt);          return (            <div className="space-y-4">              {pending.length > 0 && (                <div className="divide-y divide-border rounded-lg border border-border">                  {pending.map((invite) => (                    <div key={invite.id} className="flex items-center gap-3 p-3 text-sm">                      <span className="font-mono text-xs">{invite.code}</span>                      <span className="flex-1 truncate text-muted-foreground">                        {invite.label ? `${invite.label} · ` : ""}                        {invite.role}                      </span>                      <CopyButton value={`${window.location.origin}/join/${invite.code}`} />                      <Button variant="ghost" size="sm" onClick={() => revoke(invite.id)}>                        Revoke                      </Button>                    </div>                  ))}                </div>              )}              {used.length > 0 && (                <div className="space-y-1">                  <p className="text-xs font-medium text-muted-foreground">Redeemed</p>                  <div className="divide-y divide-border rounded-lg border border-border">                    {used.map((invite) => (                      <div key={invite.id} className="flex items-center gap-3 p-3 text-sm">                        <span className="font-mono text-xs text-muted-foreground">                          {invite.code}                        </span>                        <span className="flex-1 truncate text-muted-foreground">                          {invite.label ? `${invite.label} · ` : ""}                          used by {invite.usedByEmail}                        </span>                      </div>                    ))}                  </div>                </div>              )}              {pending.length === 0 && used.length === 0 && (                <Notice>No invite links yet — create one to add someone.</Notice>              )}            </div>          );        },      })}    </section>  );} function Notice({ children, tone }: { children: React.ReactNode; tone?: "destructive" }) {  return (    <div      className={`rounded-md border border-border bg-card p-4 text-sm ${        tone === "destructive" ? "text-destructive" : "text-muted-foreground"      }`}    >      {children}    </div>  );}