From a52013fd0e91c97bae11d382a268036210f68762 Mon Sep 17 00:00:00 2001 From: wil Date: Wed, 28 Aug 2024 22:38:28 -0400 Subject: [PATCH] authentication signals and auto login --- src/app/global.css | 19 ++- src/components/hoc/index.ts | 1 + src/components/hoc/withProtectedRoute.tsx | 59 +++++++++ src/components/ui/index.ts | 1 + src/components/ui/spinner.tsx | 14 ++ src/enums/Authentication.ts | 15 +++ src/enums/Billing.ts | 11 ++ src/enums/Users.ts | 6 + src/enums/index.ts | 3 + src/models/Billing.tsx | 17 +++ src/models/Users.ts | 50 ++++++++ src/models/index.ts | 2 + src/pages/main.tsx | 13 +- src/signals/authentication/authentication.ts | 48 +++++++ src/signals/authentication/fetchers.ts | 127 +++++++++++++++++++ src/signals/authentication/index.ts | 2 + src/signals/authentication/utilities.ts | 40 ++++++ src/signals/index.ts | 1 + tailwind.config.js | 21 ++- 19 files changed, 440 insertions(+), 10 deletions(-) create mode 100644 src/components/hoc/index.ts create mode 100644 src/components/hoc/withProtectedRoute.tsx create mode 100644 src/components/ui/index.ts create mode 100644 src/components/ui/spinner.tsx create mode 100644 src/enums/Authentication.ts create mode 100644 src/enums/Billing.ts create mode 100644 src/enums/Users.ts create mode 100644 src/enums/index.ts create mode 100644 src/models/Billing.tsx create mode 100644 src/models/Users.ts create mode 100644 src/models/index.ts create mode 100644 src/signals/authentication/authentication.ts create mode 100644 src/signals/authentication/fetchers.ts create mode 100644 src/signals/authentication/index.ts create mode 100644 src/signals/authentication/utilities.ts create mode 100644 src/signals/index.ts diff --git a/src/app/global.css b/src/app/global.css index 144756cea2..701408031c 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -3,7 +3,10 @@ @tailwind utilities; @layer base { - html{ + :root { + @apply bg-ui-background text-white; + } + html { @apply font-sans; } h1 { @@ -18,7 +21,10 @@ h4 { @apply text-lg font-medium; } - /* h5 { } */ + h5 { + @apply text-base; + } + h6 { @apply text-sm; } @@ -26,12 +32,13 @@ @apply text-sm font-light; } a { - @apply font-light hover:text-primary-400; + @apply font-light text-primary-500 hover:text-primary-300; } a:visited { - @apply hover:text-primary-700; + @apply text-primary-700 hover:text-primary-500; } + input { - @apply px-2 py-1 border-0 outline-none -outline-offset-2 focus:outline-primary-400 focus:ring-0 ; + @apply border-0 px-2 py-1 outline-none -outline-offset-2 focus:outline-primary-500 focus:ring-0; } -} \ No newline at end of file +} diff --git a/src/components/hoc/index.ts b/src/components/hoc/index.ts new file mode 100644 index 0000000000..625025a76c --- /dev/null +++ b/src/components/hoc/index.ts @@ -0,0 +1 @@ +export * from "./withProtectedRoute"; diff --git a/src/components/hoc/withProtectedRoute.tsx b/src/components/hoc/withProtectedRoute.tsx new file mode 100644 index 0000000000..3b1da04066 --- /dev/null +++ b/src/components/hoc/withProtectedRoute.tsx @@ -0,0 +1,59 @@ +import { ComponentType, useEffect, useRef } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { useSignalEffect } from "@preact/signals-react/runtime"; +import { Spinner } from "~/components/ui"; +import { authentication, persistLogin } from "~/signals"; +import { AUTH_STATUS } from "~/enums"; + +export const withProtectionRoute =

( + Component: ComponentType

, +) => + function ProtectionRoute(rest: P) { + useSignalEffect(() => { + persistLogin(); + }); + + const { status, userInfo } = authentication; + //render according to auth status + if ( + status.value === AUTH_STATUS.INIT || + status.value === AUTH_STATUS.LOGGING || + status.value === AUTH_STATUS.GET_USER_INFO + ) { + return ( +

+ +
+ ); + } + + if ( + status.value === AUTH_STATUS.LOGGED_IN && + userInfo.value && + userInfo.value.can_access_studio + ) { + return ; + } + + return ; + }; + +const RedirectToLogin = () => { + const navigate = useNavigate(); + const { pathname } = useLocation(); + const timeoutTimer = useRef(undefined); + + useEffect(() => { + if (!timeoutTimer.current) { + timeoutTimer.current = setTimeout( + () => navigate(`/login?redirect=${pathname}`), + 2000, + ); + } + }, [navigate, pathname]); + return ( +
+ +
+ ); +}; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000000..eff29707a5 --- /dev/null +++ b/src/components/ui/index.ts @@ -0,0 +1 @@ +export * from "./spinner"; diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx new file mode 100644 index 0000000000..8874f60e61 --- /dev/null +++ b/src/components/ui/spinner.tsx @@ -0,0 +1,14 @@ +import { faSpinnerThird } from "@fortawesome/pro-thin-svg-icons"; +import { twMerge } from "tailwind-merge"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +export const Spinner = ({ className }: { className?: string }) => { + return ( +
+ +
+ ); +}; diff --git a/src/enums/Authentication.ts b/src/enums/Authentication.ts new file mode 100644 index 0000000000..ce32954d6a --- /dev/null +++ b/src/enums/Authentication.ts @@ -0,0 +1,15 @@ +export enum AUTH_STATUS { + INIT = "INIT", + NO_ACCESS = "no_access", + LOGGED_IN = "logged_in", + LOGGING = "logging", + GET_USER_INFO = "get_user_info", + LOGGED_OUT = "logged_out", +} + +export enum AUTH_ERROR_FALLBACKS { + CreateSessionError = "Unknown Error during Create Session", + DestorySessionError = "Unknown Error during Destroy Session", + GetSessionError = "Unknown Error During Get Session", + Unauthorized = "User Unauthorized", +} diff --git a/src/enums/Billing.ts b/src/enums/Billing.ts new file mode 100644 index 0000000000..c602de1b8a --- /dev/null +++ b/src/enums/Billing.ts @@ -0,0 +1,11 @@ +export enum SubscriptionNamespace { + FAKEYOU = "fakeyou", +} +export enum SubscriptionProduct { + PLUS = "fakeyou_plus", + PRO = "fakeyou_pro", + ELITE = "fakeyou_elite", +} +export enum LoyaltyProgram { + CONTRIBUTOR = "fakeyou_contributor", +} diff --git a/src/enums/Users.ts b/src/enums/Users.ts new file mode 100644 index 0000000000..76c4b77ae8 --- /dev/null +++ b/src/enums/Users.ts @@ -0,0 +1,6 @@ +export enum USER_FEATURE_FLAGS { + EXPLORE_MEDIA = "explore_media", + STUDIO = "studio", + UPLOAD_3D = "upload_3d", + VIDEO_STYLE_TRANSFER = "video_style_transfer", +} diff --git a/src/enums/index.ts b/src/enums/index.ts new file mode 100644 index 0000000000..1086d43982 --- /dev/null +++ b/src/enums/index.ts @@ -0,0 +1,3 @@ +export * from "./Authentication"; +export * from "./Billing"; +export * from "./Users"; diff --git a/src/models/Billing.tsx b/src/models/Billing.tsx new file mode 100644 index 0000000000..fc832816d2 --- /dev/null +++ b/src/models/Billing.tsx @@ -0,0 +1,17 @@ +import { + LoyaltyProgram, + SubscriptionNamespace, + SubscriptionProduct, +} from "~/enums"; + +export interface ActiveSubscriptions { + // user's list of subscriptions + active_subscriptions: Subscription[]; + // special programs that the user could be in + maybe_loyalty_program?: LoyaltyProgram; +} + +export interface Subscription { + namespace: SubscriptionNamespace; + product_slug: SubscriptionProduct; +} diff --git a/src/models/Users.ts b/src/models/Users.ts new file mode 100644 index 0000000000..fc0caf6f17 --- /dev/null +++ b/src/models/Users.ts @@ -0,0 +1,50 @@ +import { USER_FEATURE_FLAGS } from "~/enums"; + +export interface CoreInfo { + default_avatar: { + color_index: number; + image_index: number; + }; + display_name: string; + gravatar_hash: string; + user_token: string; + username: string; +} + +export interface UserInfo { + user_token: string; + username: string; + display_name: string; + email_gravatar_hash: string; + + core_info: CoreInfo; + + // Rollout feature flags + can_access_studio: boolean; + maybe_feature_flags: USER_FEATURE_FLAGS[]; + + // Usage + can_use_tts: boolean; + can_use_w2l: boolean; + can_delete_own_tts_results: boolean; + can_delete_own_w2l_results: boolean; + can_delete_own_account: boolean; + + // Contribution + can_upload_tts_models: boolean; + can_upload_w2l_templates: boolean; + can_delete_own_tts_models: boolean; + can_delete_own_w2l_templates: boolean; + + // Moderation + can_approve_w2l_templates: boolean; + can_edit_other_users_profiles: boolean; + can_edit_other_users_tts_models: boolean; + can_edit_other_users_w2l_templates: boolean; + can_delete_other_users_tts_models: boolean; + can_delete_other_users_tts_results: boolean; + can_delete_other_users_w2l_templates: boolean; + can_delete_other_users_w2l_results: boolean; + can_ban_users: boolean; + can_delete_users: boolean; +} diff --git a/src/models/index.ts b/src/models/index.ts new file mode 100644 index 0000000000..557be9b1ae --- /dev/null +++ b/src/models/index.ts @@ -0,0 +1,2 @@ +export * from "./Billing"; +export * from "./Users"; diff --git a/src/pages/main.tsx b/src/pages/main.tsx index 8624f38632..6ee12ce441 100644 --- a/src/pages/main.tsx +++ b/src/pages/main.tsx @@ -1,3 +1,10 @@ -export const Main = () => { - return
Main
; -}; +import { withProtectionRoute } from "~/components/hoc"; +import { authentication } from "~/signals"; + +export const Main = withProtectionRoute(() => { + return ( +
+

you are logged in as {authentication.userInfo.value?.display_name}

+
+ ); +}); diff --git a/src/signals/authentication/authentication.ts b/src/signals/authentication/authentication.ts new file mode 100644 index 0000000000..b7a1128225 --- /dev/null +++ b/src/signals/authentication/authentication.ts @@ -0,0 +1,48 @@ +import { computed, signal } from "@preact/signals-core"; +import { ActiveSubscriptions, UserInfo } from "~/models"; +import { AUTH_STATUS, LoyaltyProgram, USER_FEATURE_FLAGS } from "~/enums"; + +const status = signal(AUTH_STATUS.INIT); +const userInfo = signal(undefined); +const activeSubs = signal(undefined); + +const canUpload3D = computed(() => { + if (!userInfo.value || !userInfo.value.maybe_feature_flags) { + return undefined; + } + return userInfo.value.maybe_feature_flags.includes( + USER_FEATURE_FLAGS.UPLOAD_3D, + ); +}); + +const hasAccess = computed(() => { + if ( + userInfo.value === undefined || + userInfo.value.can_access_studio === undefined + ) { + return undefined; + } + return userInfo.value.can_access_studio; +}); + +const hasPremium = computed(() => { + if (activeSubs.value === undefined) { + return undefined; + } + if ( + activeSubs.value.active_subscriptions.length > 0 || + activeSubs.value.maybe_loyalty_program === LoyaltyProgram.CONTRIBUTOR + ) { + return true; + } + return false; +}); + +export const authentication = { + status, + userInfo, + activeSubs, + canUpload3D, + hasAccess, + hasPremium, +}; diff --git a/src/signals/authentication/fetchers.ts b/src/signals/authentication/fetchers.ts new file mode 100644 index 0000000000..a46f2c4372 --- /dev/null +++ b/src/signals/authentication/fetchers.ts @@ -0,0 +1,127 @@ +import { authentication } from "./authentication"; +import { UsersApi } from "~/Classes/ApiManager/UsersApi"; +import { BillingApi } from "~/Classes/ApiManager/BillingApi"; + +import { + updateActiveSubscriptions, + updateAuthStatus, + updateUserInfo, + setLogoutStates, +} from "./utilities"; +import { AUTH_STATUS } from "~/enums"; + +export const logout = async ( + failureCallback?: (errorMessage: string) => void, +) => { + const usersApi = new UsersApi(); + const logoutResponse = await usersApi.Logout(); + if (!logoutResponse.success && failureCallback) { + failureCallback( + logoutResponse.errorMessage || "Unknown Error during Destroy Session", + ); + } + // if success, nothing + // regarldess of success/fail, clear the state and localstorage + setLogoutStates(); +}; + +export const login = async ({ + usernameOrEmail, + password, +}: { + usernameOrEmail: string; + password: string; + failureCallback?: () => void; +}) => { + updateAuthStatus(AUTH_STATUS.LOGGING); + + const usersApi = new UsersApi(); + const loginResponse = await usersApi.Login({ usernameOrEmail, password }); + if (!loginResponse.success || !loginResponse.data) { + setLogoutStates(); + return; + } + + // technically user is login with the system now, HOWEVER, + // in storyteller studio, only having a sesison is not enough, + // we need session info and active subscription info as well + getUserInfoAndSubcriptions(); +}; + +export const signUp = async ({ + username, + email, + password, + passwordConfirmation, +}: { + username: string; + email: string; + password: string; + passwordConfirmation: string; +}) => { + updateAuthStatus(AUTH_STATUS.LOGGING); + + const usersApi = new UsersApi(); + const response = await usersApi.Signup({ + email, + password, + passwordConfirmation, + username, + }); + console.log(response); + if (!response.success || !response.data) { + setLogoutStates(); + return response.errorMessage ?? "Unknown error"; + } + + // technically user is login with the system now, HOWEVER, + // in storyteller studio, only having a sesison is not enough, + // we need session info and active subscription info as well + getUserInfoAndSubcriptions(); + return ""; +}; + +export const persistLogin = async () => { + //Only run First Load, return if not + if (authentication.status.value !== AUTH_STATUS.INIT) { + return; + } + getUserInfoAndSubcriptions(); +}; + +async function getUserInfoAndSubcriptions() { + updateAuthStatus(AUTH_STATUS.GET_USER_INFO); + const usersApi = new UsersApi(); + const sessionResponse = await usersApi.GetSession(); + if ( + !sessionResponse.success || + !sessionResponse.data || + !sessionResponse.data.user + ) { + setLogoutStates(); + return; + } + + if (sessionResponse.data && !sessionResponse.data.user.can_access_studio) { + updateAuthStatus(AUTH_STATUS.NO_ACCESS); + return; + } + + const billingApi = new BillingApi(); + const subscriptionsResponse = await billingApi.ListActiveSubscriptions(); + if ( + !subscriptionsResponse.success || + !subscriptionsResponse.data || + !subscriptionsResponse.data.active_subscriptions + ) { + setLogoutStates(); + return; + } + + updateUserInfo(sessionResponse.data.user); + updateActiveSubscriptions({ + maybe_loyalty_program: subscriptionsResponse.data.maybe_loyalty_program, + active_subscriptions: subscriptionsResponse.data.active_subscriptions || [], + }); + updateAuthStatus(AUTH_STATUS.LOGGED_IN); +} diff --git a/src/signals/authentication/index.ts b/src/signals/authentication/index.ts new file mode 100644 index 0000000000..bf6a51e48f --- /dev/null +++ b/src/signals/authentication/index.ts @@ -0,0 +1,2 @@ +export * from "./authentication"; +export * from "./fetchers"; diff --git a/src/signals/authentication/utilities.ts b/src/signals/authentication/utilities.ts new file mode 100644 index 0000000000..b8845db13c --- /dev/null +++ b/src/signals/authentication/utilities.ts @@ -0,0 +1,40 @@ +import { AUTH_STATUS } from "~/enums"; +import { UserInfo, ActiveSubscriptions } from "~/models"; +import { + authentication, + // flushAllBackgroundLoadedMedia +} from "~/signals"; + +export const updateAuthStatus = (newStatus: AUTH_STATUS) => { + authentication.status.value = newStatus; +}; + +export const updateUserInfo = ( + newInfo: UserInfo | undefined, + flush?: boolean, +) => { + if (newInfo && !flush) { + //case of updating UserInfo partly + authentication.userInfo.value = { + ...authentication.userInfo.value, + ...newInfo, + }; + } else { + //case of setting a new User + //case of deleting userInfo + authentication.userInfo.value = newInfo; + } +}; +export const updateActiveSubscriptions = ( + activeSubs: ActiveSubscriptions | undefined, +) => { + authentication.activeSubs.value = activeSubs; +}; + +// this function is not exposed, only the logout function is +export const setLogoutStates = () => { + updateAuthStatus(AUTH_STATUS.LOGGED_OUT); + updateUserInfo(undefined); + updateActiveSubscriptions(undefined); + // flushAllBackgroundLoadedMedia(); +}; diff --git a/src/signals/index.ts b/src/signals/index.ts new file mode 100644 index 0000000000..947dc27329 --- /dev/null +++ b/src/signals/index.ts @@ -0,0 +1 @@ +export * from "./authentication"; diff --git a/tailwind.config.js b/tailwind.config.js index 10838a1cbc..58efac081f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -18,7 +18,6 @@ export default { // common colors white: colors.white, black: colors.black, - gray: colors.slate, //use slate instead of gray red: colors.red, oragne: colors.orange, yellow: colors.yellow, @@ -27,6 +26,13 @@ export default { indigo: colors.indigo, purple: colors.purple, + // utility colors + error: colors.red[500], + warning: colors.yellow[500], + success: colors.green[500], + info: colors.sky[500], + + // brand colors primary: { //sunglo DEFAULT: "#e66462", //=500 @@ -72,6 +78,19 @@ export default { 900: "#1e505b", 950: "#0e353e", }, + + ui: { + 900: "#1a1a27", + background: "#1a1a27", + 700: "#242433", + panel: "#242433", + 500: "#393948", + border: "#393948", + 300: "#515168", + divider: "#515168", + 100: "#676781", + button: "#676781", + }, }, }, plugins: [],