197 lines
6.3 KiB
TypeScript
197 lines
6.3 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useState } from "react";
|
|
import { useNavigate } from "react-router";
|
|
import { useApi } from "../api/context";
|
|
import { errorMessage } from "../state/actions";
|
|
import { PLAYER_STATE_KEY } from "../state/player";
|
|
import { Button, Sheet } from "../ui";
|
|
|
|
const BACKUP_KEY = ["backup-code"] as const;
|
|
|
|
/** "Back up company": make a code (shown once), or replace the existing one (decision 0.5.72). */
|
|
export function BackupCompany() {
|
|
const api = useApi();
|
|
const queryClient = useQueryClient();
|
|
const [open, setOpen] = useState(false);
|
|
const [copied, setCopied] = useState(false);
|
|
const status = useQuery({
|
|
queryKey: BACKUP_KEY,
|
|
queryFn: () => api.call("getBackupCode", {}),
|
|
enabled: open,
|
|
});
|
|
const create = useMutation({
|
|
mutationFn: () => api.call("createBackupCode", {}),
|
|
onSuccess: () => void queryClient.invalidateQueries({ queryKey: BACKUP_KEY }),
|
|
});
|
|
const code = create.data?.code;
|
|
|
|
const close = () => {
|
|
setOpen(false);
|
|
setCopied(false);
|
|
create.reset();
|
|
};
|
|
const copy = (text: string) => {
|
|
void navigator.clipboard
|
|
?.writeText(text)
|
|
.then(() => setCopied(true))
|
|
.catch(() => setCopied(false));
|
|
};
|
|
|
|
let body;
|
|
if (code) {
|
|
body = (
|
|
<>
|
|
<p
|
|
aria-label="Backup code"
|
|
className="mt-4 rounded-xl bg-space-950/80 p-4 text-center font-mono text-2xl font-bold tracking-widest text-amber-200 ring-1 ring-amber-400/40 select-all"
|
|
>
|
|
{code}
|
|
</p>
|
|
<p className="mt-3 text-sm text-ink-200">
|
|
Keep this somewhere safe. Anyone with this code can open your company.
|
|
</p>
|
|
<Button block className="mt-3" onClick={() => copy(code)}>
|
|
{copied ? "Copied" : "Copy"}
|
|
</Button>
|
|
</>
|
|
);
|
|
} else if (status.isPending) {
|
|
body = <p className="mt-4 text-sm text-ink-400">Checking…</p>;
|
|
} else if (status.data?.hasCode) {
|
|
body = (
|
|
<>
|
|
<p className="mt-3 text-sm text-ink-200">
|
|
You already have a backup code. It is only shown when it is made, so if you lost it, make
|
|
a new one.
|
|
</p>
|
|
<p className="mt-2 text-sm text-amber-200">
|
|
Your old code will stop working as soon as you generate a new one.
|
|
</p>
|
|
<Button
|
|
needsServer
|
|
block
|
|
className="mt-3"
|
|
disabled={create.isPending}
|
|
onClick={() => create.mutate()}
|
|
>
|
|
Generate a new code
|
|
</Button>
|
|
</>
|
|
);
|
|
} else {
|
|
body = (
|
|
<>
|
|
<p className="mt-3 text-sm text-ink-200">
|
|
A backup code lets you open this company on any device, or get it back if this one is
|
|
lost. No email or password.
|
|
</p>
|
|
<Button
|
|
needsServer
|
|
block
|
|
className="mt-3"
|
|
disabled={create.isPending}
|
|
onClick={() => create.mutate()}
|
|
>
|
|
Create a backup code
|
|
</Button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Button needsServer variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
|
Back up company
|
|
</Button>
|
|
{open && (
|
|
<Sheet titleId="backup-title" title="Back up your company" eyebrow="Backup code">
|
|
{body}
|
|
{(create.isError || status.isError) && (
|
|
<p role="alert" className="mt-2 text-sm text-rose-300">
|
|
{errorMessage(create.error ?? status.error)}
|
|
</p>
|
|
)}
|
|
<Button variant="secondary" block className="mt-3" onClick={close}>
|
|
Done
|
|
</Button>
|
|
</Sheet>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* "Restore a company": enter a backup code to open that company on this
|
|
* device. `pastTutorial` warns that this device's own company is left behind.
|
|
*/
|
|
export function RestoreCompany({ pastTutorial }: { pastTutorial: boolean }) {
|
|
const api = useApi();
|
|
const queryClient = useQueryClient();
|
|
const navigate = useNavigate();
|
|
const [open, setOpen] = useState(false);
|
|
const [code, setCode] = useState("");
|
|
const restore = useMutation({
|
|
mutationFn: () => api.call("restoreCompany", { code }),
|
|
onSuccess: async ({ state }) => {
|
|
queryClient.setQueryData(PLAYER_STATE_KEY, state);
|
|
await queryClient.invalidateQueries({ queryKey: ["board"] });
|
|
setOpen(false);
|
|
void navigate("/");
|
|
},
|
|
});
|
|
|
|
return (
|
|
<>
|
|
<Button needsServer variant="secondary" size="sm" onClick={() => setOpen(true)}>
|
|
Restore a company
|
|
</Button>
|
|
{open && (
|
|
<Sheet titleId="restore-title" title="Restore a company" eyebrow="Backup code" layer="z-30">
|
|
{pastTutorial && (
|
|
<p
|
|
role="note"
|
|
className="mt-3 rounded-lg bg-amber-950/50 p-2 text-sm text-amber-200 ring-1 ring-amber-400/40"
|
|
>
|
|
This device's current company will be left behind. Back it up first if you want
|
|
to keep it.
|
|
</p>
|
|
)}
|
|
<form
|
|
className="mt-3 space-y-3"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (code.trim()) restore.mutate();
|
|
}}
|
|
>
|
|
<label className="block text-sm">
|
|
<span className="text-ink-300">Backup code</span>
|
|
<input
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
autoCapitalize="characters"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
placeholder="XXXXX-XXXXX-XXXXX-XXXXX"
|
|
className="mt-1 w-full rounded-lg border border-space-600 bg-space-950/70 px-3 py-2 font-mono tracking-widest text-ink-50 focus:border-amber-400 focus:outline-none"
|
|
/>
|
|
</label>
|
|
{restore.isError && (
|
|
<p role="alert" className="text-sm text-rose-300">
|
|
{errorMessage(restore.error)}
|
|
</p>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button needsServer type="submit" block disabled={!code.trim() || restore.isPending}>
|
|
Restore
|
|
</Button>
|
|
<Button variant="secondary" block onClick={() => setOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Sheet>
|
|
)}
|
|
</>
|
|
);
|
|
}
|