mirror of
https://github.com/zoriya/Kyoo.git
synced 2026-03-28 04:17:50 -04:00
Add admin users page
This commit is contained in:
parent
f96e67eda7
commit
cd7c350b20
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { AddPage } from "~/ui/admin/add";
|
||||
|
||||
export { ErrorBoundary } from "~/ui/error-boundary";
|
||||
|
||||
export default AddPage;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { MatchPage } from "~/ui/admin/match";
|
||||
|
||||
export { ErrorBoundary } from "~/ui/error-boundary";
|
||||
|
||||
export default MatchPage;
|
||||
|
||||
5
front/src/app/(app)/admin/users.tsx
Normal file
5
front/src/app/(app)/admin/users.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { AdminUsersPage } from "~/ui/admin/users";
|
||||
|
||||
export { ErrorBoundary } from "~/ui/error-boundary";
|
||||
|
||||
export default AdminUsersPage;
|
||||
32
front/src/models/auth-info.ts
Normal file
32
front/src/models/auth-info.ts
Normal 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>;
|
||||
@ -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>;
|
||||
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./users";
|
||||
export * from "./videos-modal";
|
||||
|
||||
244
front/src/ui/admin/users.tsx
Normal file
244
front/src/ui/admin/users.tsx
Normal 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"],
|
||||
});
|
||||
@ -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>;
|
||||
|
||||
@ -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")}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user