mirror of
				https://github.com/steveiliop56/tinyauth.git
				synced 2025-11-04 08:05:42 +00:00 
			
		
		
		
	feat: multiple oauth providers (#355)
* feat: add flag decoder (candidate) * refactor: finalize flags decoder * feat: add env decoder * feat: add oauth config parsing logic * feat: implement backend logic for multiple oauth providers * feat: implement multiple oauth providers in the frontend * feat: add some default icons * chore: add credits for parser * feat: style oauth auto redirect screen * fix: bot suggestions * refactor: rework decoders using simpler and more efficient pattern * refactor: rework oauth name database migration
This commit is contained in:
		
							
								
								
									
										18
									
								
								frontend/src/components/icons/microsoft.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								frontend/src/components/icons/microsoft.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
import type { SVGProps } from "react";
 | 
			
		||||
 | 
			
		||||
export function MicrosoftIcon(props: SVGProps<SVGSVGElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <svg
 | 
			
		||||
      xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
      width="2em"
 | 
			
		||||
      height="2em"
 | 
			
		||||
      viewBox="0 0 256 256"
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <path fill="#f1511b" d="M121.666 121.666H0V0h121.666z"></path>
 | 
			
		||||
      <path fill="#80cc28" d="M256 121.666H134.335V0H256z"></path>
 | 
			
		||||
      <path fill="#00adef" d="M121.663 256.002H0V134.336h121.663z"></path>
 | 
			
		||||
      <path fill="#fbbc09" d="M256 256.002H134.335V134.336H256z"></path>
 | 
			
		||||
    </svg>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
import type { SVGProps } from "react";
 | 
			
		||||
 | 
			
		||||
export function GenericIcon(props: SVGProps<SVGSVGElement>) {
 | 
			
		||||
export function OAuthIcon(props: SVGProps<SVGSVGElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <svg
 | 
			
		||||
      xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
							
								
								
									
										20
									
								
								frontend/src/components/icons/pocket-id.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								frontend/src/components/icons/pocket-id.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
import type { SVGProps } from "react";
 | 
			
		||||
 | 
			
		||||
export function PocketIDIcon(props: SVGProps<SVGSVGElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <svg
 | 
			
		||||
      xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
      xmlSpace="preserve"
 | 
			
		||||
      width={512}
 | 
			
		||||
      height={512}
 | 
			
		||||
      viewBox="0 0 512 512"
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <circle cx="256" cy="256" r="256" />
 | 
			
		||||
      <path
 | 
			
		||||
        d="M268.6 102.4c64.4 0 116.8 52.4 116.8 116.7 0 25.3-8 49.4-23 69.6-14.8 19.9-35 34.3-58.4 41.7l-6.5 2-15.5-76.2 4.3-2c14-6.7 23-21.1 23-36.6 0-22.4-18.2-40.6-40.6-40.6S228 195.2 228 217.6c0 15.5 9 29.8 23 36.6l4.2 2-25 153.4h-69.5V102.4z"
 | 
			
		||||
        className="fill-white"
 | 
			
		||||
      />
 | 
			
		||||
    </svg>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										26
									
								
								frontend/src/components/icons/tailscale.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								frontend/src/components/icons/tailscale.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
import type { SVGProps } from "react";
 | 
			
		||||
 | 
			
		||||
export function TailscaleIcon(props: SVGProps<SVGSVGElement>) {
 | 
			
		||||
  return (
 | 
			
		||||
    <svg
 | 
			
		||||
      xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
      xmlSpace="preserve"
 | 
			
		||||
      width={512}
 | 
			
		||||
      height={512}
 | 
			
		||||
      viewBox="0 0 512 512"
 | 
			
		||||
      {...props}
 | 
			
		||||
    >
 | 
			
		||||
      <path
 | 
			
		||||
        className="opacity-80"
 | 
			
		||||
        fill="currentColor"
 | 
			
		||||
        d="M65.6 318.1c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9S1.8 219 1.8 254.2s28.6 63.9 63.8 63.9m191.6 0c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9m0 193.9c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9m189.2-193.9c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9"
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <path
 | 
			
		||||
        d="M65.6 127.7c35.3 0 63.9-28.6 63.9-63.9S100.9 0 65.6 0 1.8 28.6 1.8 63.9s28.6 63.8 63.8 63.8m0 384.3c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.8 28.7-63.8 63.9S30.4 512 65.6 512m191.6-384.3c35.3 0 63.9-28.6 63.9-63.9S292.5 0 257.2 0s-63.9 28.6-63.9 63.9 28.6 63.8 63.9 63.8m189.2 0c35.3 0 63.9-28.6 63.9-63.9S481.6 0 446.4 0c-35.3 0-63.9 28.6-63.9 63.9s28.6 63.8 63.9 63.8m0 384.3c35.3 0 63.9-28.6 63.9-63.9s-28.6-63.9-63.9-63.9-63.9 28.6-63.9 63.9 28.6 63.9 63.9 63.9"
 | 
			
		||||
        className="opacity-20"
 | 
			
		||||
        fill="currentColor"
 | 
			
		||||
      />
 | 
			
		||||
    </svg>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -14,6 +14,9 @@
 | 
			
		||||
    "loginOauthFailSubtitle": "Failed to get OAuth URL",
 | 
			
		||||
    "loginOauthSuccessTitle": "Redirecting",
 | 
			
		||||
    "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
 | 
			
		||||
    "loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
 | 
			
		||||
    "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
 | 
			
		||||
    "loginOauthAutoRedirectButton": "Redirect now",
 | 
			
		||||
    "continueTitle": "Continue",
 | 
			
		||||
    "continueRedirectingTitle": "Redirecting...",
 | 
			
		||||
    "continueRedirectingSubtitle": "You should be redirected to the app soon",
 | 
			
		||||
 
 | 
			
		||||
@@ -14,6 +14,9 @@
 | 
			
		||||
    "loginOauthFailSubtitle": "Failed to get OAuth URL",
 | 
			
		||||
    "loginOauthSuccessTitle": "Redirecting",
 | 
			
		||||
    "loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
 | 
			
		||||
    "loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
 | 
			
		||||
    "loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
 | 
			
		||||
    "loginOauthAutoRedirectButton": "Redirect now",
 | 
			
		||||
    "continueTitle": "Continue",
 | 
			
		||||
    "continueRedirectingTitle": "Redirecting...",
 | 
			
		||||
    "continueRedirectingSubtitle": "You should be redirected to the app soon",
 | 
			
		||||
 
 | 
			
		||||
@@ -70,7 +70,7 @@ export const ContinuePage = () => {
 | 
			
		||||
    const reveal = setTimeout(() => {
 | 
			
		||||
      setLoading(false);
 | 
			
		||||
      setShowRedirectButton(true);
 | 
			
		||||
    }, 1000);
 | 
			
		||||
    }, 5000);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      clearTimeout(auto);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,13 +1,18 @@
 | 
			
		||||
import { LoginForm } from "@/components/auth/login-form";
 | 
			
		||||
import { GenericIcon } from "@/components/icons/generic";
 | 
			
		||||
import { GithubIcon } from "@/components/icons/github";
 | 
			
		||||
import { GoogleIcon } from "@/components/icons/google";
 | 
			
		||||
import { MicrosoftIcon } from "@/components/icons/microsoft";
 | 
			
		||||
import { OAuthIcon } from "@/components/icons/oauth";
 | 
			
		||||
import { PocketIDIcon } from "@/components/icons/pocket-id";
 | 
			
		||||
import { TailscaleIcon } from "@/components/icons/tailscale";
 | 
			
		||||
import { Button } from "@/components/ui/button";
 | 
			
		||||
import {
 | 
			
		||||
  Card,
 | 
			
		||||
  CardHeader,
 | 
			
		||||
  CardTitle,
 | 
			
		||||
  CardDescription,
 | 
			
		||||
  CardContent,
 | 
			
		||||
  CardFooter,
 | 
			
		||||
} from "@/components/ui/card";
 | 
			
		||||
import { OAuthButton } from "@/components/ui/oauth-button";
 | 
			
		||||
import { SeperatorWithChildren } from "@/components/ui/separator";
 | 
			
		||||
@@ -17,28 +22,40 @@ import { useIsMounted } from "@/lib/hooks/use-is-mounted";
 | 
			
		||||
import { LoginSchema } from "@/schemas/login-schema";
 | 
			
		||||
import { useMutation } from "@tanstack/react-query";
 | 
			
		||||
import axios, { AxiosError } from "axios";
 | 
			
		||||
import { useEffect, useRef } from "react";
 | 
			
		||||
import { useEffect, useRef, useState } from "react";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { Navigate, useLocation } from "react-router";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
 | 
			
		||||
const iconMap: Record<string, React.ReactNode> = {
 | 
			
		||||
  google: <GoogleIcon />,
 | 
			
		||||
  github: <GithubIcon />,
 | 
			
		||||
  tailscale: <TailscaleIcon />,
 | 
			
		||||
  microsoft: <MicrosoftIcon />,
 | 
			
		||||
  pocketid: <PocketIDIcon />,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const LoginPage = () => {
 | 
			
		||||
  const { isLoggedIn } = useUserContext();
 | 
			
		||||
  const { configuredProviders, title, oauthAutoRedirect, genericName } =
 | 
			
		||||
    useAppContext();
 | 
			
		||||
  const { providers, title, oauthAutoRedirect } = useAppContext();
 | 
			
		||||
  const { search } = useLocation();
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
  const isMounted = useIsMounted();
 | 
			
		||||
  const [oauthAutoRedirectHandover, setOauthAutoRedirectHandover] =
 | 
			
		||||
    useState(false);
 | 
			
		||||
  const [showRedirectButton, setShowRedirectButton] = useState(false);
 | 
			
		||||
 | 
			
		||||
  const redirectTimer = useRef<number | null>(null);
 | 
			
		||||
  const redirectButtonTimer = useRef<number | null>(null);
 | 
			
		||||
 | 
			
		||||
  const searchParams = new URLSearchParams(search);
 | 
			
		||||
  const redirectUri = searchParams.get("redirect_uri");
 | 
			
		||||
 | 
			
		||||
  const oauthConfigured =
 | 
			
		||||
    configuredProviders.filter((provider) => provider !== "username").length >
 | 
			
		||||
    0;
 | 
			
		||||
  const userAuthConfigured = configuredProviders.includes("username");
 | 
			
		||||
  const oauthProviders = providers.filter(
 | 
			
		||||
    (provider) => provider.id !== "username",
 | 
			
		||||
  );
 | 
			
		||||
  const userAuthConfigured =
 | 
			
		||||
    providers.find((provider) => provider.id === "username") !== undefined;
 | 
			
		||||
 | 
			
		||||
  const oauthMutation = useMutation({
 | 
			
		||||
    mutationFn: (provider: string) =>
 | 
			
		||||
@@ -56,6 +73,7 @@ export const LoginPage = () => {
 | 
			
		||||
      }, 500);
 | 
			
		||||
    },
 | 
			
		||||
    onError: () => {
 | 
			
		||||
      setOauthAutoRedirectHandover(false);
 | 
			
		||||
      toast.error(t("loginOauthFailTitle"), {
 | 
			
		||||
        description: t("loginOauthFailSubtitle"),
 | 
			
		||||
      });
 | 
			
		||||
@@ -96,12 +114,16 @@ export const LoginPage = () => {
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (isMounted()) {
 | 
			
		||||
      if (
 | 
			
		||||
        oauthConfigured &&
 | 
			
		||||
        configuredProviders.includes(oauthAutoRedirect) &&
 | 
			
		||||
        oauthProviders.length !== 0 &&
 | 
			
		||||
        providers.find((provider) => provider.id === oauthAutoRedirect) &&
 | 
			
		||||
        !isLoggedIn &&
 | 
			
		||||
        redirectUri
 | 
			
		||||
      ) {
 | 
			
		||||
        setOauthAutoRedirectHandover(true);
 | 
			
		||||
        oauthMutation.mutate(oauthAutoRedirect);
 | 
			
		||||
        redirectButtonTimer.current = window.setTimeout(() => {
 | 
			
		||||
          setShowRedirectButton(true);
 | 
			
		||||
        }, 5000);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }, []);
 | 
			
		||||
@@ -109,6 +131,8 @@ export const LoginPage = () => {
 | 
			
		||||
  useEffect(
 | 
			
		||||
    () => () => {
 | 
			
		||||
      if (redirectTimer.current) clearTimeout(redirectTimer.current);
 | 
			
		||||
      if (redirectButtonTimer.current)
 | 
			
		||||
        clearTimeout(redirectButtonTimer.current);
 | 
			
		||||
    },
 | 
			
		||||
    [],
 | 
			
		||||
  );
 | 
			
		||||
@@ -126,61 +150,63 @@ export const LoginPage = () => {
 | 
			
		||||
    return <Navigate to="/logout" replace />;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (oauthAutoRedirectHandover) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Card className="min-w-xs sm:min-w-sm">
 | 
			
		||||
        <CardHeader>
 | 
			
		||||
          <CardTitle className="text-3xl">
 | 
			
		||||
            {t("loginOauthAutoRedirectTitle")}
 | 
			
		||||
          </CardTitle>
 | 
			
		||||
          <CardDescription>
 | 
			
		||||
            {t("loginOauthAutoRedirectSubtitle")}
 | 
			
		||||
          </CardDescription>
 | 
			
		||||
        </CardHeader>
 | 
			
		||||
        {showRedirectButton && (
 | 
			
		||||
          <CardFooter className="flex flex-col items-stretch">
 | 
			
		||||
            <Button
 | 
			
		||||
              onClick={() => {
 | 
			
		||||
                window.location.replace(oauthMutation.data?.data.url);
 | 
			
		||||
              }}
 | 
			
		||||
            >
 | 
			
		||||
              {t("loginOauthAutoRedirectButton")}
 | 
			
		||||
            </Button>
 | 
			
		||||
          </CardFooter>
 | 
			
		||||
        )}
 | 
			
		||||
      </Card>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  return (
 | 
			
		||||
    <Card className="min-w-xs sm:min-w-sm">
 | 
			
		||||
      <CardHeader>
 | 
			
		||||
        <CardTitle className="text-center text-3xl">{title}</CardTitle>
 | 
			
		||||
        {configuredProviders.length > 0 && (
 | 
			
		||||
        {providers.length > 0 && (
 | 
			
		||||
          <CardDescription className="text-center">
 | 
			
		||||
            {oauthConfigured ? t("loginTitle") : t("loginTitleSimple")}
 | 
			
		||||
            {oauthProviders.length !== 0
 | 
			
		||||
              ? t("loginTitle")
 | 
			
		||||
              : t("loginTitleSimple")}
 | 
			
		||||
          </CardDescription>
 | 
			
		||||
        )}
 | 
			
		||||
      </CardHeader>
 | 
			
		||||
      <CardContent className="flex flex-col gap-4">
 | 
			
		||||
        {oauthConfigured && (
 | 
			
		||||
        {oauthProviders.length !== 0 && (
 | 
			
		||||
          <div className="flex flex-col gap-2 items-center justify-center">
 | 
			
		||||
            {configuredProviders.includes("google") && (
 | 
			
		||||
            {oauthProviders.map((provider) => (
 | 
			
		||||
              <OAuthButton
 | 
			
		||||
                title="Google"
 | 
			
		||||
                icon={<GoogleIcon />}
 | 
			
		||||
                key={provider.id}
 | 
			
		||||
                title={provider.name}
 | 
			
		||||
                icon={iconMap[provider.id] ?? <OAuthIcon />}
 | 
			
		||||
                className="w-full"
 | 
			
		||||
                onClick={() => oauthMutation.mutate("google")}
 | 
			
		||||
                onClick={() => oauthMutation.mutate(provider.id)}
 | 
			
		||||
                loading={
 | 
			
		||||
                  oauthMutation.isPending &&
 | 
			
		||||
                  oauthMutation.variables === "google"
 | 
			
		||||
                  oauthMutation.variables === provider.id
 | 
			
		||||
                }
 | 
			
		||||
                disabled={oauthMutation.isPending || loginMutation.isPending}
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
            {configuredProviders.includes("github") && (
 | 
			
		||||
              <OAuthButton
 | 
			
		||||
                title="Github"
 | 
			
		||||
                icon={<GithubIcon />}
 | 
			
		||||
                className="w-full"
 | 
			
		||||
                onClick={() => oauthMutation.mutate("github")}
 | 
			
		||||
                loading={
 | 
			
		||||
                  oauthMutation.isPending &&
 | 
			
		||||
                  oauthMutation.variables === "github"
 | 
			
		||||
                }
 | 
			
		||||
                disabled={oauthMutation.isPending || loginMutation.isPending}
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
            {configuredProviders.includes("generic") && (
 | 
			
		||||
              <OAuthButton
 | 
			
		||||
                title={genericName}
 | 
			
		||||
                icon={<GenericIcon />}
 | 
			
		||||
                className="w-full"
 | 
			
		||||
                onClick={() => oauthMutation.mutate("generic")}
 | 
			
		||||
                loading={
 | 
			
		||||
                  oauthMutation.isPending &&
 | 
			
		||||
                  oauthMutation.variables === "generic"
 | 
			
		||||
                }
 | 
			
		||||
                disabled={oauthMutation.isPending || loginMutation.isPending}
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
            ))}
 | 
			
		||||
          </div>
 | 
			
		||||
        )}
 | 
			
		||||
        {userAuthConfigured && oauthConfigured && (
 | 
			
		||||
        {userAuthConfigured && oauthProviders.length !== 0 && (
 | 
			
		||||
          <SeperatorWithChildren>{t("loginDivider")}</SeperatorWithChildren>
 | 
			
		||||
        )}
 | 
			
		||||
        {userAuthConfigured && (
 | 
			
		||||
@@ -189,7 +215,7 @@ export const LoginPage = () => {
 | 
			
		||||
            loading={loginMutation.isPending || oauthMutation.isPending}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
        {configuredProviders.length == 0 && (
 | 
			
		||||
        {providers.length == 0 && (
 | 
			
		||||
          <p className="text-center text-red-600 max-w-sm">
 | 
			
		||||
            {t("failedToFetchProvidersTitle")}
 | 
			
		||||
          </p>
 | 
			
		||||
 
 | 
			
		||||
@@ -6,9 +6,7 @@ import {
 | 
			
		||||
  CardHeader,
 | 
			
		||||
  CardTitle,
 | 
			
		||||
} from "@/components/ui/card";
 | 
			
		||||
import { useAppContext } from "@/context/app-context";
 | 
			
		||||
import { useUserContext } from "@/context/user-context";
 | 
			
		||||
import { capitalize } from "@/lib/utils";
 | 
			
		||||
import { useMutation } from "@tanstack/react-query";
 | 
			
		||||
import axios from "axios";
 | 
			
		||||
import { useEffect, useRef } from "react";
 | 
			
		||||
@@ -17,8 +15,7 @@ import { Navigate } from "react-router";
 | 
			
		||||
import { toast } from "sonner";
 | 
			
		||||
 | 
			
		||||
export const LogoutPage = () => {
 | 
			
		||||
  const { provider, username, isLoggedIn, email } = useUserContext();
 | 
			
		||||
  const { genericName } = useAppContext();
 | 
			
		||||
  const { provider, username, isLoggedIn, email, oauthName } = useUserContext();
 | 
			
		||||
  const { t } = useTranslation();
 | 
			
		||||
 | 
			
		||||
  const redirectTimer = useRef<number | null>(null);
 | 
			
		||||
@@ -67,8 +64,7 @@ export const LogoutPage = () => {
 | 
			
		||||
              }}
 | 
			
		||||
              values={{
 | 
			
		||||
                username: email,
 | 
			
		||||
                provider:
 | 
			
		||||
                  provider === "generic" ? genericName : capitalize(provider),
 | 
			
		||||
                provider: oauthName,
 | 
			
		||||
              }}
 | 
			
		||||
            />
 | 
			
		||||
          ) : (
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,19 @@
 | 
			
		||||
import { z } from "zod";
 | 
			
		||||
 | 
			
		||||
export const providerSchema = z.object({
 | 
			
		||||
  id: z.string(),
 | 
			
		||||
  name: z.string(),
 | 
			
		||||
  oauth: z.boolean(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const appContextSchema = z.object({
 | 
			
		||||
  configuredProviders: z.array(z.string()),
 | 
			
		||||
  providers: z.array(providerSchema),
 | 
			
		||||
  title: z.string(),
 | 
			
		||||
  genericName: z.string(),
 | 
			
		||||
  appUrl: z.string(),
 | 
			
		||||
  cookieDomain: z.string(),
 | 
			
		||||
  forgotPasswordMessage: z.string(),
 | 
			
		||||
  oauthAutoRedirect: z.enum(["none", "github", "google", "generic"]),
 | 
			
		||||
  backgroundImage: z.string(),
 | 
			
		||||
  oauthAutoRedirect: z.string(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export type AppContextSchema = z.infer<typeof appContextSchema>;
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@ export const userContextSchema = z.object({
 | 
			
		||||
  provider: z.string(),
 | 
			
		||||
  oauth: z.boolean(),
 | 
			
		||||
  totpPending: z.boolean(),
 | 
			
		||||
  oauthName: z.string(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export type UserContextSchema = z.infer<typeof userContextSchema>;
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user