Add admin users page

This commit is contained in:
Zoe Roux 2026-03-26 17:48:38 +01:00
parent f96e67eda7
commit cd7c350b20
No known key found for this signature in database
14 changed files with 317 additions and 44 deletions

View File

@ -383,6 +383,7 @@ func main() {
r.DELETE("/sessions", h.Logout)
r.DELETE("/sessions/:id", h.Logout)
r.GET("/users/:id/sessions", h.ListUserSessions)
r.GET("/users/me/sessions", h.ListMySessions)
g.GET("/oidc/login/:provider", h.OidcLogin)
r.DELETE("/oidc/login/:provider", h.OidcUnlink)

View File

@ -297,7 +297,12 @@
"set-permissions": "Set permissions",
"delete": "Delete user",
"unverifed": "Unverifed",
"verify": "Verify user"
"verify": "Verify user",
"table": {
"username": "Username",
"lastSeen": "Last seen",
"oidc": "OIDC"
}
},
"scanner": {
"label": "Scanner",

View File

@ -1,3 +1,5 @@
import { AddPage } from "~/ui/admin/add";
export { ErrorBoundary } from "~/ui/error-boundary";
export default AddPage;

View File

@ -1,3 +1,5 @@
import { MatchPage } from "~/ui/admin/match";
export { ErrorBoundary } from "~/ui/error-boundary";
export default MatchPage;

View File

@ -0,0 +1,5 @@
import { AdminUsersPage } from "~/ui/admin/users";
export { ErrorBoundary } from "~/ui/error-boundary";
export default AdminUsersPage;

View File

@ -0,0 +1,32 @@
import { Platform } from "react-native";
import z from "zod/v4";
export const AuthInfo = z
.object({
publicUrl: z.string(),
allowRegister: z.boolean().optional().default(true),
oidc: z.record(
z.string(),
z.object({
name: z.string(),
logo: z.string().nullable().optional(),
}),
),
})
.transform((x) => {
const redirect = `${Platform.OS === "web" ? x.publicUrl : "kyoo://"}/oidc-callback?apiUrl=${x.publicUrl}`;
return {
...x,
oidc: Object.fromEntries(
Object.entries(x.oidc).map(([provider, info]) => [
provider,
{
...info,
connect: `${x.publicUrl}/auth/oidc/login/${provider}?redirectUrl=${encodeURIComponent(redirect)}`,
link: `${x.publicUrl}/auth/oidc/login/${provider}?redirectUrl=${encodeURIComponent(`${redirect}&link=true`)}`,
},
]),
),
};
});
export type AuthInfo = z.infer<typeof AuthInfo>;

View File

@ -6,7 +6,10 @@ export const User = z
username: z.string(),
email: z.string(),
hasPassword: z.boolean().default(true),
createdDate: z.coerce.date().default(new Date()),
lastSeen: z.coerce.date().default(new Date()),
claims: z.object({
verified: z.boolean().default(true),
permissions: z.array(z.string()),
settings: z
.object({
@ -45,9 +48,8 @@ export const User = z
})
.transform((x) => ({
...x,
logo: `auth/users/${x.id}/logo`,
// isVerified: x.permissions.length > 0,
isAdmin: true, //x.permissions?.includes("admin.write"),
logo: `/auth/users/${x.id}/logo`,
isAdmin: x.claims.permissions.includes("users.write"),
}));
export type User = z.infer<typeof User>;

View File

@ -58,7 +58,7 @@ export const Avatar = <AsProps = ViewProps>({
resizeMode="cover"
source={{ uri: src }}
alt={alt}
className="absolute inset-0"
className="absolute inset-0 bg-slate-200 dark:bg-slate-200"
/>
)}
{!src && !placeholder && (

View File

@ -1 +1,2 @@
export * from "./users";
export * from "./videos-modal";

View File

@ -0,0 +1,244 @@
import Admin from "@material-symbols/svg-400/rounded/admin_panel_settings.svg";
import Check from "@material-symbols/svg-400/rounded/check-fill.svg";
import Close from "@material-symbols/svg-400/rounded/close-fill.svg";
import MoreVert from "@material-symbols/svg-400/rounded/more_vert.svg";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { User } from "~/models";
import { AuthInfo } from "~/models/auth-info";
import {
Avatar,
Container,
HR,
Icon,
IconButton,
Menu,
P,
Skeleton,
SubP,
tooltip,
} from "~/primitives";
import {
InfiniteFetch,
type QueryIdentifier,
useFetch,
useMutation,
} from "~/query";
import { cn } from "~/utils";
const formatLastSeen = (date: Date) => {
return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
};
const UserRow = ({
id,
logo,
username,
lastSeen,
oidc,
oidcInfo,
isVerified,
isAdmin,
}: {
id: string;
logo: string;
username: string;
lastSeen: Date;
oidc: User["oidc"];
oidcInfo?: AuthInfo["oidc"];
isVerified: boolean;
isAdmin: boolean;
}) => {
const { t } = useTranslation();
const oidcProviders = Object.keys(oidc);
const { mutateAsync } = useMutation({
path: ["auth", "users", id],
compute: (action: "verify" | "admin" | "delete") => ({
method: action === "delete" ? "DELETE" : "PATCH",
body: {
claims:
action === "verify"
? { verified: true }
: {
permissions: [
"users.read",
"users.write",
"users.delete",
"apikeys.read",
"apikeys.write",
"core.read",
"core.write",
"core.play",
"scanner.trigger",
"scanner.guess",
"scanner.search",
"scanner.add",
],
},
},
}),
invalidate: ["auth", "users"],
});
return (
<View className="flex-row items-center gap-4 px-3">
<Avatar src={logo} placeholder={username} className="h-8 w-8" />
<View className="min-w-0 flex-1">
<P
numberOfLines={1}
className="font-semibold text-slate-900 dark:text-slate-200"
>
{username}
</P>
<SubP className="sm:hidden">{formatLastSeen(lastSeen)}</SubP>
</View>
<SubP className="hidden w-45 shrink-0 text-right sm:flex">
{formatLastSeen(lastSeen)}
</SubP>
<View className="w-20 shrink-0 flex-row justify-end gap-1">
{oidcProviders.length === 0 ? (
<SubP>-</SubP>
) : (
oidcProviders.map((provider) => (
<Avatar
key={provider}
src={oidcInfo?.[provider]?.logo ?? undefined}
placeholder={provider}
{...tooltip(oidcInfo?.[provider]?.name ?? provider)}
/>
))
)}
</View>
<Icon
icon={isAdmin ? Admin : isVerified ? Check : Close}
className={cn(
"fill-amber-500 dark:fill-amber-500",
isVerified && "fill-emerald-500 dark:fill-emerald-500",
isAdmin && "fill-accent dark:fill-accent",
)}
{...tooltip(
t(
isAdmin
? "admin.users.adminUser"
: isVerified
? "admin.users.regularUser"
: "admin.users.unverifed",
),
)}
/>
<Menu Trigger={IconButton} icon={MoreVert}>
{!isVerified && (
<Menu.Item
label={t("admin.users.verify")}
icon={Check}
onSelect={async () => await mutateAsync("verify")}
/>
)}
<Menu.Item
label={t("admin.users.set-permissions")}
icon={Admin}
onSelect={async () => await mutateAsync("admin")}
/>
<HR />
<Menu.Item
label={t("admin.users.delete")}
icon={Close}
onSelect={async () => await mutateAsync("delete")}
/>
</Menu>
</View>
);
};
UserRow.Loader = () => {
return (
<View className="flex-row items-center gap-4 px-3 py-2">
<Avatar.Loader className="h-8 w-8" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="hidden h-4 w-50 sm:flex" />
<View className="w-20 flex-row gap-1">
<Avatar.Loader />
<Avatar.Loader />
</View>
<Skeleton className="h-6 w-6" />
<Icon icon={MoreVert} />
</View>
);
};
const UsersHeader = () => {
const { t } = useTranslation();
return (
<View className="mt-4 px-3 pt-4 pb-1">
<View className="flex-row items-center gap-4 pb-2">
<View className="w-8" />
<SubP className="flex-1 font-semibold uppercase">
{t("admin.users.table.username")}
</SubP>
<SubP className="hidden w-40 shrink-0 text-right font-semibold uppercase sm:flex">
{t("admin.users.table.lastSeen")}
</SubP>
<SubP className="w-20 shrink-0 text-right font-semibold uppercase">
{t("admin.users.table.oidc")}
</SubP>
<View className="w-22" />
</View>
<HR />
</View>
);
};
export const AdminUsersPage = () => {
const { data } = useFetch(AdminUsersPage.authQuery());
return (
<InfiniteFetch
query={AdminUsersPage.query()}
layout={{
layout: "vertical",
numColumns: 1,
size: 76,
gap: 8,
}}
Header={
<View>
<Container>
<UsersHeader />
</Container>
</View>
}
Render={({ item }) => (
<Container>
<UserRow
{...item}
oidcInfo={data?.oidc}
isVerified={item.claims.verified}
/>
</Container>
)}
Loader={() => (
<Container>
<UserRow.Loader />
</Container>
)}
Divider={() => (
<Container>
<HR />
</Container>
)}
/>
);
};
AdminUsersPage.query = (): QueryIdentifier<User> => ({
parser: User,
path: ["auth", "users"],
infinite: true,
});
AdminUsersPage.authQuery = (): QueryIdentifier<AuthInfo> => ({
parser: AuthInfo,
path: ["auth", "info"],
});

View File

@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Image, Platform, View } from "react-native";
import { z } from "zod/v4";
import { AuthInfo } from "~/models/auth-info";
import { Button, HR, Link, P, Skeleton } from "~/primitives";
import { Fetch, type QueryIdentifier } from "~/query";
@ -81,33 +82,3 @@ OidcLogin.query = (apiUrl?: string): QueryIdentifier<AuthInfo> => ({
parser: AuthInfo,
options: { apiUrl },
});
const AuthInfo = z
.object({
publicUrl: z.string(),
allowRegister: z.boolean().optional().default(true),
oidc: z.record(
z.string(),
z.object({
name: z.string(),
logo: z.string().nullable().optional(),
}),
),
})
.transform((x) => {
const redirect = `${Platform.OS === "web" ? x.publicUrl : "kyoo://"}/oidc-callback?apiUrl=${x.publicUrl}`;
return {
...x,
oidc: Object.fromEntries(
Object.entries(x.oidc).map(([provider, info]) => [
provider,
{
...info,
connect: `${x.publicUrl}/auth/oidc/login/${provider}?redirectUrl=${encodeURIComponent(redirect)}`,
link: `${x.publicUrl}/auth/oidc/login/${provider}?redirectUrl=${encodeURIComponent(`${redirect}&link=true`)}`,
},
]),
),
};
});
type AuthInfo = z.infer<typeof AuthInfo>;

View File

@ -122,7 +122,9 @@ export const AccountSettings = () => {
</Preference>
<Preference
icon={AccountCircle}
customIcon={<Avatar src={account.logo} />}
customIcon={
<Avatar src={account.logo} placeholder={account.username} />
}
label={t("settings.account.avatar.label")}
description={t("settings.account.avatar.description")}
>

View File

@ -55,12 +55,14 @@ export const SettingsContainer = ({
<H1 className="my-2 text-4xl">{title}</H1>
{extraTop}
<View className="rounded bg-card">
{Children.map(children, (x, i) => (
<Fragment key={i}>
{i !== 0 && <HR className="my-2" />}
{x}
</Fragment>
))}
{Children.toArray(children)
.filter((x) => x)
.map((x, i) => (
<Fragment key={i}>
{i !== 0 && <HR className="my-2" />}
{x}
</Fragment>
))}
</View>
{extra}
</Container>

View File

@ -7,13 +7,12 @@ import { Image } from "react-native";
import { type KyooError, User } from "~/models";
import { Button, IconButton, Link, P, Skeleton, tooltip } from "~/primitives";
import { type QueryIdentifier, useFetch, useMutation } from "~/query";
import { OidcLogin } from "../login/oidc";
import { Preference, SettingsContainer } from "./base";
export const OidcSettings = () => {
const { t } = useTranslation();
const [unlinkError, setUnlinkError] = useState<string | null>(null);
const { data } = useFetch(OidcLogin.query());
const { data } = useFetch(OidcSettings.authQuery());
const { data: user } = useFetch(OidcSettings.query());
const { mutateAsync: unlinkAccount } = useMutation({
method: "DELETE",
@ -104,3 +103,8 @@ OidcSettings.query = (): QueryIdentifier<User> => ({
path: ["auth", "users", "me"],
parser: User,
});
OidcSettings.authQuery = (): QueryIdentifier<AuthInfo> => ({
path: ["auth", "info"],
parser: AuthInfo,
});