diff --git a/back/src/Kyoo.Authentication/Models/DTO/ServerInfo.cs b/back/src/Kyoo.Authentication/Models/DTO/ServerInfo.cs index 9a581ed9..6286cb7c 100644 --- a/back/src/Kyoo.Authentication/Models/DTO/ServerInfo.cs +++ b/back/src/Kyoo.Authentication/Models/DTO/ServerInfo.cs @@ -46,6 +46,11 @@ public class ServerInfo /// The list of permissions available for the guest account. /// public List GuestPermissions { get; set; } + + /// + /// Check if kyoo's setup is finished. + /// + public SetupStep SetupStatus { get; set; } } public class OidcInfo @@ -60,3 +65,24 @@ public class OidcInfo /// public string? LogoUrl { get; set; } } + +/// +/// Check if kyoo's setup is finished. +/// +public enum SetupStep +{ + /// + /// No admin account exists, create an account before exposing kyoo to the internet! + /// + MissingAdminAccount, + + /// + /// No video was registered on kyoo, have you configured the rigth library path? + /// + NoVideoFound, + + /// + /// Setup finished! + /// + Done, +} diff --git a/back/src/Kyoo.Core/Controllers/MiscRepository.cs b/back/src/Kyoo.Core/Controllers/MiscRepository.cs index 8b014840..48916ad8 100644 --- a/back/src/Kyoo.Core/Controllers/MiscRepository.cs +++ b/back/src/Kyoo.Core/Controllers/MiscRepository.cs @@ -25,6 +25,7 @@ using System.Threading.Tasks; using Dapper; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; +using Kyoo.Authentication.Models; using Kyoo.Postgresql; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -129,6 +130,15 @@ public class MiscRepository( .OrderBy(x => x.RefreshDate) .ToListAsync(); } + + public async Task GetSetupStep() + { + bool hasUser = await context.Users.AnyAsync(); + if (!hasUser) + return SetupStep.MissingAdminAccount; + bool hasItem = await context.Movies.AnyAsync() || await context.Shows.AnyAsync(); + return hasItem ? SetupStep.Done : SetupStep.NoVideoFound; + } } public class RefreshableItem diff --git a/back/src/Kyoo.Authentication/Views/InfoApi.cs b/back/src/Kyoo.Core/Views/InfoApi.cs similarity index 86% rename from back/src/Kyoo.Authentication/Views/InfoApi.cs rename to back/src/Kyoo.Core/Views/InfoApi.cs index ceb8ab4e..48cd132d 100644 --- a/back/src/Kyoo.Authentication/Views/InfoApi.cs +++ b/back/src/Kyoo.Core/Views/InfoApi.cs @@ -18,8 +18,10 @@ using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using Kyoo.Abstractions.Models.Attributes; using Kyoo.Authentication.Models; +using Kyoo.Core.Controllers; using Microsoft.AspNetCore.Mvc; using static Kyoo.Abstractions.Models.Utils.Constants; @@ -31,9 +33,9 @@ namespace Kyoo.Authentication.Views; [ApiController] [Route("info")] [ApiDefinition("Info", Group = UsersGroup)] -public class InfoApi(PermissionOption options) : ControllerBase +public class InfoApi(PermissionOption options, MiscRepository info) : ControllerBase { - public ActionResult GetInfo() + public async Task> GetInfo() { return Ok( new ServerInfo() @@ -48,6 +50,7 @@ public class InfoApi(PermissionOption options) : ControllerBase new() { DisplayName = x.Value.DisplayName, LogoUrl = x.Value.LogoUrl, } )) .ToDictionary(x => x.Key, x => x.Value), + SetupStatus = await info.GetSetupStep(), } ); } diff --git a/front/apps/web/src/pages/_app.tsx b/front/apps/web/src/pages/_app.tsx index 7b3c156c..28db2a8c 100755 --- a/front/apps/web/src/pages/_app.tsx +++ b/front/apps/web/src/pages/_app.tsx @@ -27,12 +27,15 @@ import { ConnectionErrorContext, type QueryIdentifier, type QueryPage, + type ServerInfo, ServerInfoP, + SetupStep, UserP, createQueryClient, fetchQuery, getTokenWJ, setSsrApiUrl, + useFetch, useUserTheme, } from "@kyoo/models"; import { getCurrentAccount, readCookie, updateAccount } from "@kyoo/models/src/account-internal"; @@ -51,7 +54,8 @@ import arrayShuffle from "array-shuffle"; import NextApp, { type AppContext, type AppProps } from "next/app"; import { Poppins } from "next/font/google"; import Head from "next/head"; -import { type ComponentType, useContext, useState } from "react"; +import { type NextRouter, useRouter } from "next/router"; +import { type ComponentType, useContext, useEffect, useState } from "react"; import { Tooltip } from "react-tooltip"; import superjson from "superjson"; import { StyleRegistryProvider, useMobileHover, useStyleRegistry, useTheme } from "yoshiki/web"; @@ -131,6 +135,28 @@ const ConnectionErrorVerifier = ({ return ; }; +const SetupChecker = () => { + const { data } = useFetch({ path: ["info"], parser: ServerInfoP }); + const router = useRouter(); + + const step = data?.setupStatus; + + useEffect(() => { + if (!step) return; + if (step !== SetupStep.Done && !SetupChecker.isRouteAllowed(router, step)) + router.push(`/setup?step=${step}`); + if (step === SetupStep.Done && router.route === "/setup") router.replace("/"); + }, [router.route, step, router]); + + return null; +}; + +SetupChecker.isRouteAllowed = (router: NextRouter, step: SetupStep) => + (router.route === "/setup" && router.query.step === step) || + router.route === "/register" || + router.route.startsWith("/login") || + router.route === "/settings"; + const WithLayout = ({ Component, ...props }: { Component: ComponentType }) => { const layoutInfo = (Component as QueryPage).getLayout ?? (({ page }) => page); const { Layout, props: layoutProps } = @@ -176,6 +202,7 @@ const App = ({ Component, pageProps }: AppProps) => { /> + @@ -214,22 +241,36 @@ App.getInitialProps = async (ctx: AppContext) => { setSsrApiUrl(); + appProps.pageProps.theme = readCookie(ctx.ctx.req?.headers.cookie, "theme") ?? "auto"; + const account = readCookie(ctx.ctx.req?.headers.cookie, "account", AccountP); if (account) urls.push({ path: ["auth", "me"], parser: UserP }); const [authToken, token, error] = await getTokenWJ(account); if (error) appProps.pageProps.ssrError = error; - else { - const client = (await fetchQuery(urls, authToken))!; - appProps.pageProps.queryState = dehydrate(client); - if (account) { - appProps.pageProps.token = token; - appProps.pageProps.account = { - ...client.getQueryData(["auth", "me"]), - ...account, - }; - } + const client = (await fetchQuery(urls, authToken))!; + appProps.pageProps.queryState = dehydrate(client); + if (account) { + appProps.pageProps.token = token; + appProps.pageProps.account = { + ...client.getQueryData(["auth", "me"]), + ...account, + }; + } + + const info = client.getQueryData(["info"]); + if ( + info!.setupStatus !== SetupStep.Done && + !SetupChecker.isRouteAllowed(ctx.router, info!.setupStatus) + ) { + ctx.ctx.res!.writeHead(307, { Location: `/setup?step=${info!.setupStatus}` }); + ctx.ctx.res!.end(); + return {} as any; + } + if (info!.setupStatus === SetupStep.Done && ctx.router.route === "/setup") { + ctx.ctx.res!.writeHead(307, { Location: "/" }); + ctx.ctx.res!.end(); + return {} as any; } - appProps.pageProps.theme = readCookie(ctx.ctx.req?.headers.cookie, "theme") ?? "auto"; } catch (e) { console.error("SSR error, disabling it."); } diff --git a/front/apps/web/src/pages/setup/index.tsx b/front/apps/web/src/pages/setup/index.tsx new file mode 100644 index 00000000..022e242e --- /dev/null +++ b/front/apps/web/src/pages/setup/index.tsx @@ -0,0 +1,24 @@ +/* + * Kyoo - A portable and vast media library solution. + * Copyright (c) Kyoo. + * + * See AUTHORS.md and LICENSE file in the project root for full license information. + * + * Kyoo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Kyoo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Kyoo. If not, see . + */ + +import { SetupPage } from "@kyoo/ui"; +import { withRoute } from "~/router"; + +export default withRoute(SetupPage); diff --git a/front/packages/models/src/resources/server-info.ts b/front/packages/models/src/resources/server-info.ts index 370ba6d2..05fa90e6 100644 --- a/front/packages/models/src/resources/server-info.ts +++ b/front/packages/models/src/resources/server-info.ts @@ -33,6 +33,12 @@ export const OidcInfoP = z.object({ logoUrl: z.string().nullable(), }); +export enum SetupStep { + MissingAdminAccount = "MissingAdminAccount", + NoVideoFound = "NoVideoFound", + Done = "Done", +} + export const ServerInfoP = z .object({ /* @@ -51,6 +57,10 @@ export const ServerInfoP = z * The list of oidc providers configured for this instance of kyoo. */ oidc: z.record(z.string(), OidcInfoP), + /* + * Check if kyoo's setup is finished. + */ + setupStatus: z.nativeEnum(SetupStep), }) .transform((x) => { const baseUrl = Platform.OS === "web" ? x.publicUrl : "kyoo://"; diff --git a/front/packages/ui/src/errors/index.tsx b/front/packages/ui/src/errors/index.tsx index c7a6e21b..19c9c722 100644 --- a/front/packages/ui/src/errors/index.tsx +++ b/front/packages/ui/src/errors/index.tsx @@ -21,3 +21,4 @@ export * from "./error"; export * from "./unauthorized"; export * from "./connection"; +export * from "./setup"; diff --git a/front/packages/ui/src/errors/setup.tsx b/front/packages/ui/src/errors/setup.tsx new file mode 100644 index 00000000..ef41dd28 --- /dev/null +++ b/front/packages/ui/src/errors/setup.tsx @@ -0,0 +1,48 @@ +import { Main } from "@expo/html-elements"; +import { type QueryPage, SetupStep } from "@kyoo/models"; +import { Button, Icon, Link, P, ts } from "@kyoo/primitives"; +import Register from "@material-symbols/svg-400/rounded/app_registration.svg"; +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useRouter } from "solito/router"; +import { useYoshiki } from "yoshiki/native"; +import { Navbar, NavbarProfile } from "../navbar"; +import { KyooLongLogo } from "../navbar/icon"; + +export const SetupPage: QueryPage<{ step: SetupStep }> = ({ step }) => { + const { css } = useYoshiki(); + const { t } = useTranslation(); + const router = useRouter(); + const isValid = Object.values(SetupStep).includes(step) && step !== SetupStep.Done; + + useEffect(() => { + if (!isValid) router.replace("/"); + }, [isValid, router]); + + if (!isValid) return

Loading...

; + + return ( +
+

{t(`errors.setup.${step}`)}

+ {step === SetupStep.MissingAdminAccount && ( +
+ ); +}; + +SetupPage.getLayout = ({ page }) => { + const { css } = useYoshiki(); + + return ( + <> + } right={} /> + {page} + + ); +}; diff --git a/front/packages/ui/src/navbar/index.tsx b/front/packages/ui/src/navbar/index.tsx index ec001dc4..d2ccf7b6 100644 --- a/front/packages/ui/src/navbar/index.tsx +++ b/front/packages/ui/src/navbar/index.tsx @@ -38,19 +38,25 @@ import Login from "@material-symbols/svg-400/rounded/login.svg"; import Logout from "@material-symbols/svg-400/rounded/logout.svg"; import Search from "@material-symbols/svg-400/rounded/search-fill.svg"; import Settings from "@material-symbols/svg-400/rounded/settings.svg"; -import { forwardRef, useEffect, useRef, useState } from "react"; +import { type ReactElement, forwardRef, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Platform, type TextInput, View, type ViewProps } from "react-native"; import { useRouter } from "solito/router"; -import { type Stylable, useYoshiki } from "yoshiki/native"; +import { type Stylable, percent, useYoshiki } from "yoshiki/native"; import { AdminPage } from "../admin"; import { KyooLongLogo } from "./icon"; export const NavbarTitle = (props: Stylable & { onLayout?: ViewProps["onLayout"] }) => { const { t } = useTranslation(); + const { css } = useYoshiki(); return ( - + ); @@ -168,7 +174,11 @@ export const NavbarRight = () => { ); }; -export const Navbar = (props: Stylable) => { +export const Navbar = ({ + left, + right, + ...props +}: { left?: ReactElement | null; right?: ReactElement | null } & Stylable) => { const { css } = useYoshiki(); const { t } = useTranslation(); @@ -195,18 +205,24 @@ export const Navbar = (props: Stylable) => { props, )} > - - - theme.contrast, - })} - > - {t("navbar.browse")} - + + {left !== undefined ? ( + left + ) : ( + <> + + theme.contrast, + })} + > + {t("navbar.browse")} + + + )} { marginX: ts(2), })} /> - + {right !== undefined ? right : } ); }; diff --git a/front/translations/en.json b/front/translations/en.json index d004aed0..89a285bc 100644 --- a/front/translations/en.json +++ b/front/translations/en.json @@ -237,7 +237,11 @@ "offline": "You are not connected to internet. Try again later.", "unauthorized": "You are missing the permissions {{permission}} to access this page.", "needVerification": "Your account needs to be verified by your server administrator before you can use it.", - "needAccount": "This page can't be accessed in guest mode. You need to create an account or login." + "needAccount": "This page can't be accessed in guest mode. You need to create an account or login.", + "setup": { + "MissingAdminAccount": "No admin account has been created yet. Please register to create one.", + "NoVideoFound": "No video was found yet. Add movies or series inside your library's folder for them to show here!" + } }, "mediainfo": { "file": "File",