diff --git a/auth/main.go b/auth/main.go index d4009b7b..6c12710c 100644 --- a/auth/main.go +++ b/auth/main.go @@ -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) diff --git a/front/public/translations/en.json b/front/public/translations/en.json index d2314aec..cf878f1a 100644 --- a/front/public/translations/en.json +++ b/front/public/translations/en.json @@ -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", diff --git a/front/src/app/(app)/admin/add.tsx b/front/src/app/(app)/admin/add.tsx index 5cec8acd..9ec8f3b3 100644 --- a/front/src/app/(app)/admin/add.tsx +++ b/front/src/app/(app)/admin/add.tsx @@ -1,3 +1,5 @@ import { AddPage } from "~/ui/admin/add"; +export { ErrorBoundary } from "~/ui/error-boundary"; + export default AddPage; diff --git a/front/src/app/(app)/admin/match/[id].tsx b/front/src/app/(app)/admin/match/[id].tsx index c17804d2..024e9bd8 100644 --- a/front/src/app/(app)/admin/match/[id].tsx +++ b/front/src/app/(app)/admin/match/[id].tsx @@ -1,3 +1,5 @@ import { MatchPage } from "~/ui/admin/match"; +export { ErrorBoundary } from "~/ui/error-boundary"; + export default MatchPage; diff --git a/front/src/app/(app)/admin/users.tsx b/front/src/app/(app)/admin/users.tsx new file mode 100644 index 00000000..f33355a0 --- /dev/null +++ b/front/src/app/(app)/admin/users.tsx @@ -0,0 +1,5 @@ +import { AdminUsersPage } from "~/ui/admin/users"; + +export { ErrorBoundary } from "~/ui/error-boundary"; + +export default AdminUsersPage; diff --git a/front/src/models/auth-info.ts b/front/src/models/auth-info.ts new file mode 100644 index 00000000..c265d6a3 --- /dev/null +++ b/front/src/models/auth-info.ts @@ -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; diff --git a/front/src/models/user.ts b/front/src/models/user.ts index 495f127d..eb3c68f9 100644 --- a/front/src/models/user.ts +++ b/front/src/models/user.ts @@ -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; diff --git a/front/src/primitives/avatar.tsx b/front/src/primitives/avatar.tsx index 45a1766e..1c7399ef 100644 --- a/front/src/primitives/avatar.tsx +++ b/front/src/primitives/avatar.tsx @@ -58,7 +58,7 @@ export const Avatar = ({ resizeMode="cover" source={{ uri: src }} alt={alt} - className="absolute inset-0" + className="absolute inset-0 bg-slate-200 dark:bg-slate-200" /> )} {!src && !placeholder && ( diff --git a/front/src/ui/admin/index.tsx b/front/src/ui/admin/index.tsx index d75d2289..2b887389 100644 --- a/front/src/ui/admin/index.tsx +++ b/front/src/ui/admin/index.tsx @@ -1 +1,2 @@ +export * from "./users"; export * from "./videos-modal"; diff --git a/front/src/ui/admin/users.tsx b/front/src/ui/admin/users.tsx new file mode 100644 index 00000000..6f04d998 --- /dev/null +++ b/front/src/ui/admin/users.tsx @@ -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 ( + + + +

+ {username} +

+ {formatLastSeen(lastSeen)} +
+ + {formatLastSeen(lastSeen)} + + + {oidcProviders.length === 0 ? ( + - + ) : ( + oidcProviders.map((provider) => ( + + )) + )} + + + + {!isVerified && ( + await mutateAsync("verify")} + /> + )} + await mutateAsync("admin")} + /> +
+ await mutateAsync("delete")} + /> +
+
+ ); +}; + +UserRow.Loader = () => { + return ( + + + + + + + + + + + + ); +}; + +const UsersHeader = () => { + const { t } = useTranslation(); + + return ( + + + + + {t("admin.users.table.username")} + + + {t("admin.users.table.lastSeen")} + + + {t("admin.users.table.oidc")} + + + +
+
+ ); +}; + +export const AdminUsersPage = () => { + const { data } = useFetch(AdminUsersPage.authQuery()); + + return ( + + + + +
+ } + Render={({ item }) => ( + + + + )} + Loader={() => ( + + + + )} + Divider={() => ( + +
+
+ )} + /> + ); +}; + +AdminUsersPage.query = (): QueryIdentifier => ({ + parser: User, + path: ["auth", "users"], + infinite: true, +}); + +AdminUsersPage.authQuery = (): QueryIdentifier => ({ + parser: AuthInfo, + path: ["auth", "info"], +}); diff --git a/front/src/ui/login/oidc.tsx b/front/src/ui/login/oidc.tsx index 5bea56c9..5ee717c5 100644 --- a/front/src/ui/login/oidc.tsx +++ b/front/src/ui/login/oidc.tsx @@ -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 => ({ 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; diff --git a/front/src/ui/settings/account.tsx b/front/src/ui/settings/account.tsx index c99d1664..c2b31d32 100644 --- a/front/src/ui/settings/account.tsx +++ b/front/src/ui/settings/account.tsx @@ -122,7 +122,9 @@ export const AccountSettings = () => { } + customIcon={ + + } label={t("settings.account.avatar.label")} description={t("settings.account.avatar.description")} > diff --git a/front/src/ui/settings/base.tsx b/front/src/ui/settings/base.tsx index 12cd602b..92feb612 100644 --- a/front/src/ui/settings/base.tsx +++ b/front/src/ui/settings/base.tsx @@ -55,12 +55,14 @@ export const SettingsContainer = ({

{title}

{extraTop} - {Children.map(children, (x, i) => ( - - {i !== 0 &&
} - {x} -
- ))} + {Children.toArray(children) + .filter((x) => x) + .map((x, i) => ( + + {i !== 0 &&
} + {x} +
+ ))}
{extra} diff --git a/front/src/ui/settings/oidc.tsx b/front/src/ui/settings/oidc.tsx index f3955d51..dc330800 100644 --- a/front/src/ui/settings/oidc.tsx +++ b/front/src/ui/settings/oidc.tsx @@ -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(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 => ({ path: ["auth", "users", "me"], parser: User, }); + +OidcSettings.authQuery = (): QueryIdentifier => ({ + path: ["auth", "info"], + parser: AuthInfo, +});