Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ function Shell({ auth }: { auth: Auth }) {
}
if (
auth.user.onboarding_status === "pending" &&
auth.user.onboarding_required !== false &&
location.pathname !== "/onboarding"
)
return <Navigate to="/onboarding" replace />;
Expand Down
98 changes: 98 additions & 0 deletions apps/web/src/api/schema.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

74 changes: 61 additions & 13 deletions apps/web/src/pages/Accounts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, ApiError, result, setCsrf, type Auth } from "../api/client";
import type { components } from "../api/schema";
import BookDialog from "../components/BookDialog";
import OnboardingPolicy from "./OnboardingPolicy";
import { Link } from "react-router-dom";
import { Loading, Notice } from "../components";
import {
Expand Down Expand Up @@ -58,27 +59,35 @@ function OidcSettingsPanel() {
const [secret, setSecret] = useState("");
const [open, setOpen] = useState(false);
useEffect(() => {
if (settings.data) {
setDraft(settings.data);
setSecret("");
}
const loaded = settings.data;
if (loaded) setDraft((current) => current ?? loaded);
}, [settings.data]);
const save = useMutation({
mutationFn: async () => {
if (!draft) return;
const { secret_set, redirect_uri, ...fields } = draft;
mutationFn: async (submitted: { draft: OidcSettings; secret: string }) => {
const { secret_set, redirect_uri, ...fields } = submitted.draft;
void secret_set;
void redirect_uri;
return result(
await api.PUT("/api/auth/oidc/settings", {
body: { ...fields, client_secret: secret },
body: { ...fields, client_secret: submitted.secret },
}),
);
},
onSuccess: (saved) => {
if (!saved) return;
onSuccess: (saved, submitted) => {
client.setQueryData(["oidc-settings"], saved);
setSecret("");
// A completed save must not overwrite edits made while it was in flight.
setDraft((current) =>
current === submitted.draft
? saved
: current
? {
...current,
secret_set: saved.secret_set,
redirect_uri: saved.redirect_uri,
}
: current,
);
setSecret((current) => (current === submitted.secret ? "" : current));
},
});
const discover = useMutation({
Expand Down Expand Up @@ -133,7 +142,7 @@ function OidcSettingsPanel() {
className="panel editor"
onSubmit={(event) => {
event.preventDefault();
save.mutate();
if (draft) save.mutate({ draft, secret });
}}
>
<p className="muted">
Expand Down Expand Up @@ -236,12 +245,24 @@ function OidcSettingsPanel() {
update("default_role", event.target.value)
}
>
<option value="member">Member</option>
<option value="viewer">Viewer</option>
<option value="requester">Requester</option>
<option value="member">Member</option>
<option value="approver">Approver</option>
<option value="admin">Administrator</option>
</select>
</label>
)}
</div>
{(draft.auto_register || mapsGroups) &&
draft.default_role === "admin" && (
<p className="access-hint">
Administrator default grants full control to every new account
or synchronized account with no matching group. Choose it only
when everyone admitted by this provider should administer
Dewarr.
</p>
)}
{mapsGroups && (
<p className="access-hint">
Used when no configured group matches. Applies to existing
Expand Down Expand Up @@ -363,6 +384,32 @@ function OidcSettingsPanel() {
/>
</label>
</div>
<div className="form-row">
<label>
Requester group
<input
value={draft.requester_group}
onChange={(event) =>
update("requester_group", event.target.value)
}
/>
</label>
<label>
Approver group
<input
value={draft.approver_group}
onChange={(event) =>
update("approver_group", event.target.value)
}
/>
</label>
</div>
<p className="access-hint">
If several groups match: Administrator, Approver, Member,
Requester, then Viewer. On each sign-in, mappings replace manual
roles for accounts without a local password. Password accounts
and the last active administrator keep their existing access.
</p>
<div className="form-row">
<label>
Viewer group
Expand Down Expand Up @@ -788,6 +835,7 @@ export default function Accounts({ embedded = false }: { embedded?: boolean }) {
)}
{actor?.role === "admin" && (
<>
<OnboardingPolicy />
<OidcSettingsPanel />
<PlexSettingsPanel />
</>
Expand Down
67 changes: 67 additions & 0 deletions apps/web/src/pages/OnboardingPolicy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, result } from "../api/client";
import { Loading, Notice } from "../components";

export default function OnboardingPolicy() {
const cache = useQueryClient();
const policy = useQuery({
queryKey: ["onboarding-policy"],
queryFn: async () => result(await api.GET("/api/setup/onboarding-policy")),
});
const [draft, setDraft] = useState<boolean | null>(null);
const show = draft ?? policy.data?.show_member_onboarding ?? true;
const save = useMutation({
mutationFn: async (submitted: boolean) =>
result(
await api.PUT("/api/setup/onboarding-policy", {
body: { show_member_onboarding: submitted },
}),
),
onSuccess: (saved, submitted) => {
setDraft((current) => (current === submitted ? null : current));
cache.setQueryData(["onboarding-policy"], saved);
cache.invalidateQueries({ queryKey: ["session"] });
},
});
return (
<section className="panel editor" aria-labelledby="onboarding-policy-title">
<h2 id="onboarding-policy-title">First-run onboarding</h2>
<p className="muted">
Members and viewers can go straight to browsing on a managed
installation. They can always open their personal setup wizard from
Settings → General. Administrators, requesters, and approvers keep their
usual setup experience.
</p>
{policy.isPending ? (
<Loading />
) : policy.data ? (
<form
className="editor"
onSubmit={(event) => {
event.preventDefault();
save.mutate(show);
}}
>
<label className="check-label">
<input
type="checkbox"
checked={show}
onChange={(event) => setDraft(event.target.checked)}
/>
Show onboarding wizard to new members and viewers
</label>
<p className="access-hint">
This setting leaves personal integrations, library access, and saved
setup progress unchanged.
</p>
<button className="primary" disabled={save.isPending}>
{save.isPending ? "Saving…" : "Save onboarding preference"}
</button>
{save.isSuccess && <p role="status">Onboarding preference saved.</p>}
</form>
) : null}
<Notice error={policy.error || save.error} />
</section>
);
}
18 changes: 15 additions & 3 deletions apps/web/tests/ui/library-review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ test("library review links unmatched items to Dewarr and Hardcover books", async
page.on("pageerror", (e) => errors.push(e.message));
const matchSearches: string[] = [];
const matches: { asset: string; body: unknown }[] = [];
let releaseNextSearch!: () => void;
const nextSearch = new Promise<void>((resolve) => {
releaseNextSearch = resolve;
});
let imported = false;
const items = [
{ kind: "asset", asset: asset("a1", "Untagged Folder", ["title"]) },
Expand Down Expand Up @@ -115,6 +119,7 @@ test("library review links unmatched items to Dewarr and Hardcover books", async
data = { items: shown, total: shown.length };
} else if (path === "/api/catalog/works") {
matchSearches.push(url.searchParams.get("q") || "");
if (url.searchParams.get("q") === "The Long Way") await nextSearch;
data = { items: [work], total: 1 };
} else if (path.endsWith("/match") && method === "POST") {
const id = path.split("/").at(-2)!;
Expand Down Expand Up @@ -214,9 +219,16 @@ test("library review links unmatched items to Dewarr and Hardcover books", async
part_total: null,
},
});
await expect(page.getByRole("status")).toContainText(
"Linked “Untagged Folder”",
);
try {
// Saving and continuing can announce success while the next lookup is pending.
await expect(detail.getByRole("status")).toHaveText("Searching catalog…");
await expect(
page.getByRole("status").filter({ hasText: "Linked “Untagged Folder”" }),
).toBeVisible();
} finally {
releaseNextSearch();
}
await expect(detail.getByRole("status")).toHaveCount(0);
await expect(cards).toHaveCount(2);
await expect(
nav.getByRole("link", { name: /^Library review/ }),
Expand Down
Loading
Loading