ФУНКЦИИ - NODEDC LAUNCHER: рабочий invite onboarding
This commit is contained in:
parent
fd921cc400
commit
bd1575d18a
|
|
@ -450,6 +450,101 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
return { invite, data };
|
return { invite, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getInviteByToken(token) {
|
||||||
|
const data = readData();
|
||||||
|
const invite = findInviteByToken(data, token);
|
||||||
|
const client = findById(data.clients, invite.clientId, "client");
|
||||||
|
|
||||||
|
return {
|
||||||
|
invite: toPublicInvite(invite),
|
||||||
|
client: toPublicClient(client),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function acceptInvite(token, identity) {
|
||||||
|
const data = readData();
|
||||||
|
const invite = findInviteByToken(data, token);
|
||||||
|
const client = findById(data.clients, invite.clientId, "client");
|
||||||
|
const email = requireInviteIdentityEmail(identity);
|
||||||
|
const now = isoNow();
|
||||||
|
|
||||||
|
if (invite.email.toLowerCase() !== email) {
|
||||||
|
throw new Error("Этот инвайт выписан на другую почту");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite.status === "revoked") {
|
||||||
|
throw new Error("Инвайт отозван");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invite.status === "expired" || isInviteExpired(invite)) {
|
||||||
|
invite.status = "expired";
|
||||||
|
invite.updatedAt = now;
|
||||||
|
await writeData(data);
|
||||||
|
throw new Error("Срок действия инвайта истёк");
|
||||||
|
}
|
||||||
|
|
||||||
|
const actor = resolveActor(data, identity);
|
||||||
|
let user = data.users.find((item) => item.email.toLowerCase() === email);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
user.authentikUserId = optionalString(identity?.sub, user.authentikUserId ?? null);
|
||||||
|
user.name = optionalString(identity?.name, user.name);
|
||||||
|
user.avatarUrl = nullableStringWithFallback(identity?.avatarUrl, user.avatarUrl ?? null);
|
||||||
|
user.globalStatus = "active";
|
||||||
|
user.updatedAt = now;
|
||||||
|
} else {
|
||||||
|
user = {
|
||||||
|
id: uniqueId(data.users, "user", email),
|
||||||
|
authentikUserId: nullableString(identity?.sub),
|
||||||
|
name: optionalString(identity?.name, email.split("@")[0]),
|
||||||
|
email,
|
||||||
|
phone: null,
|
||||||
|
position: null,
|
||||||
|
notes: `Создан через инвайт клиента ${client.name}.`,
|
||||||
|
avatarUrl: nullableString(identity?.avatarUrl),
|
||||||
|
globalStatus: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
data.users.push(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
let membership = data.memberships.find((item) => item.clientId === invite.clientId && item.userId === user.id);
|
||||||
|
|
||||||
|
if (membership) {
|
||||||
|
membership.role = invite.role;
|
||||||
|
membership.status = "active";
|
||||||
|
membership.updatedAt = now;
|
||||||
|
} else {
|
||||||
|
membership = {
|
||||||
|
id: uniqueId(data.memberships, "mem", `${invite.clientId}-${email}`),
|
||||||
|
clientId: invite.clientId,
|
||||||
|
userId: user.id,
|
||||||
|
role: invite.role,
|
||||||
|
status: "active",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
data.memberships.push(membership);
|
||||||
|
}
|
||||||
|
|
||||||
|
invite.status = "accepted";
|
||||||
|
invite.updatedAt = now;
|
||||||
|
|
||||||
|
addAuditEvent(data, actor, {
|
||||||
|
action: "Инвайт принят",
|
||||||
|
objectType: "invite",
|
||||||
|
objectName: invite.email,
|
||||||
|
clientId: client.id,
|
||||||
|
result: "success",
|
||||||
|
details: `Role: ${invite.role}`,
|
||||||
|
});
|
||||||
|
markPendingSync(data, user, "user", email);
|
||||||
|
|
||||||
|
await writeData(data);
|
||||||
|
return { invite, client, user, membership, data };
|
||||||
|
}
|
||||||
|
|
||||||
async function createGroup(payload, identity) {
|
async function createGroup(payload, identity) {
|
||||||
const data = readData();
|
const data = readData();
|
||||||
const actor = resolveActor(data, identity);
|
const actor = resolveActor(data, identity);
|
||||||
|
|
@ -901,6 +996,8 @@ export function createControlPlaneStore({ projectRoot }) {
|
||||||
deleteInvite,
|
deleteInvite,
|
||||||
deleteMembership,
|
deleteMembership,
|
||||||
deleteService,
|
deleteService,
|
||||||
|
acceptInvite,
|
||||||
|
getInviteByToken,
|
||||||
getSnapshot,
|
getSnapshot,
|
||||||
readData,
|
readData,
|
||||||
replaceData,
|
replaceData,
|
||||||
|
|
@ -1028,6 +1125,50 @@ function assertGrantTargetExists(data, targetType, targetId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findInviteByToken(data, token) {
|
||||||
|
const normalizedToken = requireString(token, "token");
|
||||||
|
const invite = data.invites.find((candidate) => candidate.token === normalizedToken);
|
||||||
|
|
||||||
|
if (!invite) {
|
||||||
|
throw new Error("Инвайт не найден");
|
||||||
|
}
|
||||||
|
|
||||||
|
return invite;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireInviteIdentityEmail(identity) {
|
||||||
|
const email = typeof identity?.email === "string" ? identity.email.trim().toLowerCase() : "";
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
throw new Error("Для принятия инвайта нужна подтверждённая почта");
|
||||||
|
}
|
||||||
|
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInviteExpired(invite) {
|
||||||
|
if (!invite.expiresAt) return false;
|
||||||
|
return Number.isFinite(Date.parse(invite.expiresAt)) && Date.parse(invite.expiresAt) <= Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPublicInvite(invite) {
|
||||||
|
return {
|
||||||
|
id: invite.id,
|
||||||
|
email: invite.email,
|
||||||
|
role: invite.role,
|
||||||
|
expiresAt: invite.expiresAt,
|
||||||
|
status: isInviteExpired(invite) && invite.status !== "accepted" && invite.status !== "revoked" ? "expired" : invite.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPublicClient(client) {
|
||||||
|
return {
|
||||||
|
id: client.id,
|
||||||
|
name: client.name,
|
||||||
|
status: client.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function findById(items, id, label) {
|
function findById(items, id, label) {
|
||||||
const item = items.find((candidate) => candidate.id === id);
|
const item = items.find((candidate) => candidate.id === id);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,30 @@ app.post("/api/profile/password", requireSession, asyncRoute(async (req, res) =>
|
||||||
res.json({ data: result.data, ok: true });
|
res.json({ data: result.data, ok: true });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
app.get("/api/invites/:token", (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(controlPlaneStore.getInviteByToken(req.params.token));
|
||||||
|
} catch (error) {
|
||||||
|
sendInviteApiError(res, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/invites/:token/accept", requireSession, asyncRoute(async (req, res) => {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await controlPlaneStore.acceptInvite(req.params.token, req.nodedcSession.user);
|
||||||
|
} catch (error) {
|
||||||
|
sendInviteApiError(res, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncResult = await syncUsersToAuthentik(result.data, [result.user.id], req.nodedcSession.user);
|
||||||
|
|
||||||
|
publishControlPlaneEvent("invite.accepted", syncResult.userIds);
|
||||||
|
res.json({ ...result, data: syncResult.data });
|
||||||
|
}));
|
||||||
|
|
||||||
app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
|
app.get("/api/admin/control-plane", requireLauncherAdmin, (req, res) => {
|
||||||
res.json(controlPlaneStore.getSnapshot(req.nodedcSession.user));
|
res.json(controlPlaneStore.getSnapshot(req.nodedcSession.user));
|
||||||
});
|
});
|
||||||
|
|
@ -759,6 +783,20 @@ function sanitizeNewPassword(value) {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendInviteApiError(res, error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Инвайт недоступен";
|
||||||
|
const status =
|
||||||
|
message.includes("не найден")
|
||||||
|
? 404
|
||||||
|
: message.includes("другую почту")
|
||||||
|
? 403
|
||||||
|
: message.includes("истёк") || message.includes("отозван")
|
||||||
|
? 410
|
||||||
|
: 400;
|
||||||
|
|
||||||
|
res.status(status).json({ error: message });
|
||||||
|
}
|
||||||
|
|
||||||
function sanitizeSelfProfilePatch(payload) {
|
function sanitizeSelfProfilePatch(payload) {
|
||||||
return {
|
return {
|
||||||
name: payload?.name,
|
name: payload?.name,
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import {
|
||||||
type LauncherAuthApp,
|
type LauncherAuthApp,
|
||||||
} from "../shared/api/authApi";
|
} from "../shared/api/authApi";
|
||||||
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
|
import { updateOwnPassword, updateOwnProfile } from "../shared/api/profileApi";
|
||||||
|
import { acceptInvite, fetchPublicInvite, type PublicInviteResponse } from "../shared/api/inviteApi";
|
||||||
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
|
import { subscribeToNodeDCLogoutEvents } from "../shared/session/sessionSync";
|
||||||
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
import { loadPersistedLauncherData } from "../shared/api/storageApi";
|
||||||
import {
|
import {
|
||||||
|
|
@ -60,7 +61,15 @@ import { TopBar } from "../widgets/top-bar/TopBar";
|
||||||
|
|
||||||
let lastAuthRedirect: { url: string; startedAt: number } | null = null;
|
let lastAuthRedirect: { url: string; startedAt: number } | null = null;
|
||||||
|
|
||||||
|
type InviteFlowState =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "ready"; payload: PublicInviteResponse }
|
||||||
|
| { status: "accepting"; payload: PublicInviteResponse }
|
||||||
|
| { status: "accepted"; payload: PublicInviteResponse }
|
||||||
|
| { status: "error"; message: string; payload?: PublicInviteResponse };
|
||||||
|
|
||||||
export function LauncherApp() {
|
export function LauncherApp() {
|
||||||
|
const inviteToken = useMemo(() => parseInviteToken(window.location.pathname), []);
|
||||||
const [data, setData] = useState<LauncherData>(() => syncLauncherServiceLinks(initialLauncherData));
|
const [data, setData] = useState<LauncherData>(() => syncLauncherServiceLinks(initialLauncherData));
|
||||||
const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId);
|
const [activeProfileId, setActiveProfileId] = useState(profileOptions[0].userId);
|
||||||
const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId);
|
const [activeClientId, setActiveClientId] = useState(profileOptions[0].defaultClientId);
|
||||||
|
|
@ -70,6 +79,7 @@ export function LauncherApp() {
|
||||||
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
|
const [authApps, setAuthApps] = useState<LauncherAuthApp[] | null>(null);
|
||||||
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
|
const [profileSettingsOpen, setProfileSettingsOpen] = useState(false);
|
||||||
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
|
const [pendingAccessAssignments, setPendingAccessAssignments] = useState<Record<string, AccessAssignmentValue>>({});
|
||||||
|
const [inviteFlow, setInviteFlow] = useState<InviteFlowState | null>(() => (inviteToken ? { status: "loading" } : null));
|
||||||
|
|
||||||
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
|
const me = useMemo(() => buildMe(data, activeProfileId, activeClientId), [data, activeProfileId, activeClientId]);
|
||||||
const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0];
|
const activeProfileUser = data.users.find((user) => user.id === activeProfileId) ?? data.users[0];
|
||||||
|
|
@ -196,6 +206,27 @@ export function LauncherApp() {
|
||||||
redirectToLogin(authSession.loginUrl);
|
redirectToLogin(authSession.loginUrl);
|
||||||
}, [authSession]);
|
}, [authSession]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inviteToken || !authSession?.authenticated) return;
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
setInviteFlow({ status: "loading" });
|
||||||
|
|
||||||
|
fetchPublicInvite(inviteToken)
|
||||||
|
.then((payload) => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setInviteFlow({ status: "ready", payload });
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!isMounted) return;
|
||||||
|
setInviteFlow({ status: "error", message: error instanceof Error ? error.message : "Инвайт не найден" });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [authSession, inviteToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isRedirecting = false;
|
let isRedirecting = false;
|
||||||
|
|
||||||
|
|
@ -411,6 +442,24 @@ export function LauncherApp() {
|
||||||
applyControlPlaneMutation(createAdminInvite(invite));
|
applyControlPlaneMutation(createAdminInvite(invite));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAcceptInvite() {
|
||||||
|
if (!inviteToken || inviteFlow?.status !== "ready") return;
|
||||||
|
|
||||||
|
setInviteFlow({ status: "accepting", payload: inviteFlow.payload });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await acceptInvite(inviteToken);
|
||||||
|
setData(syncLauncherServiceLinks(result.data));
|
||||||
|
setInviteFlow({ status: "accepted", payload: inviteFlow.payload });
|
||||||
|
} catch (error) {
|
||||||
|
setInviteFlow({
|
||||||
|
status: "error",
|
||||||
|
payload: inviteFlow.payload,
|
||||||
|
message: error instanceof Error ? error.message : "Не удалось принять инвайт",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleUpdateInvite(inviteId: string, patch: Partial<Invite>) {
|
function handleUpdateInvite(inviteId: string, patch: Partial<Invite>) {
|
||||||
applyControlPlaneMutation(updateAdminInvite(inviteId, patch));
|
applyControlPlaneMutation(updateAdminInvite(inviteId, patch));
|
||||||
}
|
}
|
||||||
|
|
@ -532,6 +581,20 @@ export function LauncherApp() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (inviteToken) {
|
||||||
|
return (
|
||||||
|
<InviteFlowScreen
|
||||||
|
state={inviteFlow ?? { status: "loading" }}
|
||||||
|
currentEmail={authSession.user.email}
|
||||||
|
onAccept={() => void handleAcceptInvite()}
|
||||||
|
onGoHome={() => {
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
window.location.replace("/");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
window.location.replace(authSession.logoutUrl);
|
window.location.replace(authSession.logoutUrl);
|
||||||
};
|
};
|
||||||
|
|
@ -673,6 +736,91 @@ function resolveDefaultClientId(data: LauncherData, userId: string, requestedCli
|
||||||
return availableClientIds[0] ?? data.clients[0]?.id ?? requestedClientId;
|
return availableClientIds[0] ?? data.clients[0]?.id ?? requestedClientId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InviteFlowScreen({
|
||||||
|
state,
|
||||||
|
currentEmail,
|
||||||
|
onAccept,
|
||||||
|
onGoHome,
|
||||||
|
}: {
|
||||||
|
state: InviteFlowState;
|
||||||
|
currentEmail: string;
|
||||||
|
onAccept: () => void;
|
||||||
|
onGoHome: () => void;
|
||||||
|
}) {
|
||||||
|
const payload = "payload" in state ? state.payload : undefined;
|
||||||
|
const title =
|
||||||
|
state.status === "accepted"
|
||||||
|
? "Доступ подключён"
|
||||||
|
: state.status === "error"
|
||||||
|
? "Инвайт недоступен"
|
||||||
|
: "Приглашение в NODE.DC";
|
||||||
|
const description = payload
|
||||||
|
? `Клиент: ${payload.client.name}. Роль: ${membershipRoleLabel(payload.invite.role)}.`
|
||||||
|
: "Проверяем приглашение и платформенную сессию.";
|
||||||
|
const emailMismatch = payload && payload.invite.email.toLowerCase() !== currentEmail.toLowerCase();
|
||||||
|
const inviteStatus = payload?.invite.status;
|
||||||
|
const isAccepting = state.status === "accepting";
|
||||||
|
const canAccept = Boolean(
|
||||||
|
state.status === "ready" &&
|
||||||
|
!emailMismatch &&
|
||||||
|
inviteStatus !== "accepted" &&
|
||||||
|
inviteStatus !== "expired" &&
|
||||||
|
inviteStatus !== "revoked"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="launcher-app">
|
||||||
|
<main
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
minHeight: "100vh",
|
||||||
|
placeItems: "center",
|
||||||
|
padding: "2rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
width: "min(34rem, 100%)",
|
||||||
|
gap: "1rem",
|
||||||
|
padding: "2rem",
|
||||||
|
borderRadius: "1.75rem",
|
||||||
|
background: "linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.035))",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src="/nodedc-logo.svg" alt="NODE.DC" style={{ justifySelf: "center", width: "11rem" }} />
|
||||||
|
<p className="eyebrow" style={{ margin: 0 }}>
|
||||||
|
Invite flow
|
||||||
|
</p>
|
||||||
|
<h1 style={{ margin: 0 }}>{title}</h1>
|
||||||
|
<p style={{ margin: 0, color: "var(--text-secondary)", lineHeight: 1.5 }}>{description}</p>
|
||||||
|
{payload ? (
|
||||||
|
<p style={{ margin: 0, color: "var(--text-secondary)", lineHeight: 1.5 }}>
|
||||||
|
Инвайт: <strong>{payload.invite.email}</strong>. Текущий вход: <strong>{currentEmail}</strong>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{emailMismatch ? (
|
||||||
|
<p style={{ margin: 0, color: "var(--warning)", lineHeight: 1.45 }}>
|
||||||
|
Нужно войти под почтой, на которую выписан инвайт.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{state.status === "error" ? <p style={{ margin: 0, color: "var(--warning)", lineHeight: 1.45 }}>{state.message}</p> : null}
|
||||||
|
{state.status === "accepted" ? (
|
||||||
|
<button className="button button--primary" type="button" onClick={onGoHome}>
|
||||||
|
Перейти в витрину
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="button button--primary" type="button" disabled={!canAccept || isAccepting} onClick={onAccept}>
|
||||||
|
{isAccepting ? "Подключаем доступ" : "Принять приглашение"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AuthStateScreen({
|
function AuthStateScreen({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
|
@ -720,6 +868,19 @@ function AuthStateScreen({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseInviteToken(pathname: string) {
|
||||||
|
const match = /^\/invite\/([^/?#]+)\/?$/.exec(pathname);
|
||||||
|
return match?.[1] ? decodeURIComponent(match[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function membershipRoleLabel(role: ClientMembership["role"]) {
|
||||||
|
return {
|
||||||
|
client_owner: "Владелец клиента",
|
||||||
|
client_admin: "Администратор клиента",
|
||||||
|
member: "Участник",
|
||||||
|
}[role];
|
||||||
|
}
|
||||||
|
|
||||||
function buildLoginRedirectUrl(loginUrl?: string) {
|
function buildLoginRedirectUrl(loginUrl?: string) {
|
||||||
const url = new URL(loginUrl || "/auth/login", window.location.origin);
|
const url = new URL(loginUrl || "/auth/login", window.location.origin);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import type { ClientMembership, LauncherUser } from "../../entities/user/types";
|
||||||
|
import type { Client } from "../../entities/client/types";
|
||||||
|
import type { Invite } from "../../entities/invite/types";
|
||||||
|
import type { LauncherData } from "./mockApi";
|
||||||
|
|
||||||
|
export interface PublicInviteResponse {
|
||||||
|
invite: Pick<Invite, "id" | "email" | "role" | "expiresAt" | "status">;
|
||||||
|
client: Pick<Client, "id" | "name" | "status">;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AcceptInviteResponse {
|
||||||
|
invite: Invite;
|
||||||
|
client: Client;
|
||||||
|
user: LauncherUser;
|
||||||
|
membership: ClientMembership;
|
||||||
|
data: LauncherData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPublicInvite(token: string): Promise<PublicInviteResponse> {
|
||||||
|
return requestJson<PublicInviteResponse>(`/api/invites/${encodeURIComponent(token)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function acceptInvite(token: string): Promise<AcceptInviteResponse> {
|
||||||
|
return requestJson<AcceptInviteResponse>(`/api/invites/${encodeURIComponent(token)}/accept`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
|
||||||
|
if (!headers.has("Content-Type")) {
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorMessage(response: Response) {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: string };
|
||||||
|
return payload.error ?? response.statusText;
|
||||||
|
} catch {
|
||||||
|
return response.statusText;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue