Skip to content

Commit

Permalink
Merge pull request #201 from xmlking/feat/context-store
Browse files Browse the repository at this point in the history
 feat(console):  using svelte context managed stores
  • Loading branch information
xmlking committed Jul 5, 2024
2 parents 9d0ce2d + ee7e065 commit 69fb6ae
Show file tree
Hide file tree
Showing 34 changed files with 834 additions and 720 deletions.
1 change: 1 addition & 0 deletions .github/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# https://github.com/joshnuss/testing-with-sveltekit/blob/master/.github/workflows/ci.yml
# https://davetayls.me/blog/2023-06-12-deploying-a-monorepo-to-vercel-with-github-actions
name: CI

on:
Expand Down
3 changes: 2 additions & 1 deletion apps/console/src/lib/components/layout/header.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import LangSwitch from '$lib/components/layout/lang-switch.svelte';
// import LoadingIndicatorSpinner from '$lib/components/layout/loading-indicator-spinner.svelte';
import LoadingIndicatorBar from '$lib/components/layout/loading-indicator-bar.svelte';
import { storeTheme } from '$lib/stores';
import { elevated, isAuthenticated, user } from '$lib/stores/user';
import { getNhostClient } from '$lib/stores/nhost';
import type { DrawerSettings, ModalSettings } from '@skeletonlabs/skeleton';
import { AppBar, LightSwitch, getDrawerStore, getModalStore, popup } from '@skeletonlabs/skeleton';
import { LogoIcon } from '@spectacular/skeleton/components/logos';
Expand All @@ -29,6 +29,7 @@ const drawerStore = getDrawerStore();
// Local
let isOsMac = false;
const modalStore = getModalStore();
const { elevated, isAuthenticated, user } = getNhostClient();
// Set Search Keyboard Shortcut
if (browser) {
Expand Down
4 changes: 3 additions & 1 deletion apps/console/src/lib/components/layout/wait-for-auth.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
* Ensure that Auth is initialized before rendering the app.
* Otherwise all authorized graphql queries will get errors.
*/
import { isAuthenticated } from '$lib/stores/user';
import { getNhostClient } from '$lib/stores/nhost';
const { isAuthenticated } = getNhostClient();
</script>

{#if $isAuthenticated}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ onMount(() => {
>
<textarea use:autosize={{useJs: true}}
{...$$props}
class="textarea"
disabled={$isLoading}
value={$isLoading && $completion.length > 0 ? $completion.trim() : value}
on:change={handleChange}
Expand Down
12 changes: 4 additions & 8 deletions apps/console/src/lib/graphql/client.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import { browser } from '$app/environment';
import { invalidateAll } from '$app/navigation';
import { env } from '$env/dynamic/public';
import { HoudiniClient } from '$houdini';
import type { ClientPlugin } from '$houdini';
import { type ClientPlugin, HoudiniClient, getClientSession } from '$houdini';
import { subscription } from '$houdini/plugins';
import { accessToken as $accessToken } from '$lib/stores/user';
import { Logger, hasErrorMessage, hasErrorTypes, isErrorType } from '@spectacular/utils';
import { error, redirect } from '@sveltejs/kit';
import { createClient as createWSClient } from 'graphql-ws';
import { get } from 'svelte/store';

const url = env.PUBLIC_GRAPHQL_ENDPOINT;

const log = new Logger(browser ? 'houdini.browser.client' : 'houdini.server.client');

// in order to verify that we send metadata, we need something that will log the metadata after
Expand Down Expand Up @@ -47,14 +43,14 @@ export default new HoudiniClient({
if (session) {
log.debug('session...', { session });
}
// use client-side AT if avaiable !!!
let accessToken = session?.accessToken;
const backendToken = metadata?.backendToken;
const useRole = metadata?.useRole;
const adminSecret = metadata?.adminSecret;

// use client-side AT !!!
if (browser) {
accessToken = get($accessToken) ?? undefined;
if (browser && getClientSession()?.accessToken) {
accessToken = getClientSession().accessToken;
}

return {
Expand Down
9 changes: 6 additions & 3 deletions apps/console/src/lib/nhost.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { goto } from '$app/navigation';
import { SearchSecurityKeysStore } from '$houdini';
import { nhost, user } from '$lib/stores/user';
import { getNhostClient } from '$lib/stores/nhost';
import { Logger } from '@spectacular/utils';

/**
Expand All @@ -11,6 +11,7 @@ import { Logger } from '@spectacular/utils';
const log = new Logger('nhost.auth.brower');

export async function signUp(email: string, password: string, displayName: string) {
const nhost = getNhostClient();
const { session, error } = await nhost.auth.signUp({
email,
password,
Expand All @@ -23,24 +24,24 @@ export async function signUp(email: string, password: string, displayName: strin
throw error;
}
if (session) {
// user.set(nhost.auth.getUser());
goto('/dashboard');
}
}

export async function signIn(email: string, password: string): Promise<void> {
const nhost = getNhostClient();
const { session, error } = await nhost.auth.signIn({ email, password });
if (error) {
log.error(error);
throw error;
}
if (session) {
// user.set(nhost.auth.getUser());
goto('/dashboard');
}
}

export async function signOut() {
const nhost = getNhostClient();
const { error } = await nhost.auth.signOut();
if (error) {
log.error(error);
Expand All @@ -49,11 +50,13 @@ export async function signOut() {
}

export async function isAuthenticated(): Promise<boolean> {
const nhost = getNhostClient();
return await nhost.auth.isAuthenticatedAsync();
}

const skQuery = new SearchSecurityKeysStore().artifact.raw;
export async function hasSecurityKey(userId: string) {
const nhost = getNhostClient();
const { data, error } = await nhost.graphql.request(skQuery, { userId });
if (error) {
log.error({ error });
Expand Down
11 changes: 11 additions & 0 deletions apps/console/src/lib/server/middleware/refresh-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Logger } from '@spectacular/utils';
import type { Handle } from '@sveltejs/kit';

const log = new Logger('server:middleware:refreshToken');

export const refreshToken: Handle = async ({ event, resolve }) => {
// TODO: extract refreshToken login from auth hook
// https://github.com/Critteros/JavaFlavors/blob/main/web/src/lib/server/hooks/tokenRefresh.ts
const response = await resolve(event);
return response;
};
1 change: 0 additions & 1 deletion apps/console/src/lib/stores/loading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export class LoadingState {
constructor() {
onDestroy(() => {
this.#log.debug('onDestroy called');
// TODO Unsubscribe all listeners??
this.reset();
});
}
Expand Down
149 changes: 149 additions & 0 deletions apps/console/src/lib/stores/nhost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
import { PUBLIC_NHOST_REGION, PUBLIC_NHOST_SUBDOMAIN } from '$env/static/public';
import { extractSession, getClientSession, setClientSession } from '$houdini';
import { NHOST_SESSION_KEY } from '$lib/constants';
import { hasSecurityKey } from '$lib/nhost';
import { NhostClient, type NhostClientConstructorParams } from '@nhost/nhost-js';
import type { User } from '@nhost/nhost-js';
import { Logger } from '@spectacular/utils';
import Cookies from 'js-cookie';
import { getContext, onDestroy, setContext } from 'svelte';
import { type Readable, type Writable, derived, get, readable, readonly, writable } from 'svelte/store';

export class SvelteKitNhostClient extends NhostClient {
#log = new Logger('auth.store.client');

#user = writable<User | null>(null);
/**
* Readable: updates every time the authentication status changes from signed-in to signed-out.
*/
readonly isAuthenticated: Readable<boolean>;
/**
* Readable: updates every time the access or refresh token is changed.
*/
readonly accessToken: Readable<string | null>;

/**
* Readable: updates when `accessToken` changed.
* e.g., when `nhost.auth.elevateEmailSecurityKey(emmail)` is called or `accessToken` refeshed.
*/
readonly elevated: Readable<boolean | null>;

constructor(params: NhostClientConstructorParams) {
super({
...params,
start: browser,
autoSignIn: browser,
autoRefreshToken: browser,
clientStorageType: 'cookie',
});

this.isAuthenticated = readable<boolean>(false, (set) => {
if (browser) {
set(this.auth.isAuthenticated());
this.auth.onAuthStateChanged((event, session) => {
this.#log.debug(`The auth state has changed. State is now ${event} with session:`, { session });
switch (event) {
case 'SIGNED_IN':
set(true);
this.#user.set(session?.user || null);
break;
case 'SIGNED_OUT':
set(false);
this.#user.set(null);
Cookies.remove(NHOST_SESSION_KEY);
break;
}
});
return () => this.#log.debug('no more subscribers for isAuthenticated');
}
});

this.accessToken = readable<string | null>(null, (set) => {
if (browser) {
set(this.auth.getAccessToken() ?? null);
this.auth.onTokenChanged((session) => {
this.#log.debug('The access token refreshed:', { session });
const accessToken = session?.accessToken;
set(accessToken ?? null);
// set fresh accessToken into HoudiniClient's session (client-side only)
setClientSession({ accessToken });
// save session as cookie everytime token is refreshed or user signin via WebAuthN.
// Cookie will be removed when browser closed or user explicitly SIGNED_OUT.
Cookies.set(NHOST_SESSION_KEY, btoa(JSON.stringify(session)), {
path: '/',
sameSite: 'strict',
secure: true,
});
});
return () => this.#log.debug('no more subscribers for accessToken');
}
});

this.elevated = derived(
[this.accessToken, this.#user],
([$at, $user]) => {
return $at && $user ? this.auth.getHasuraClaim('x-hasura-auth-elevated') === $user.id : false;
},
false,
);

onDestroy(() => {
// Do cleanup ???
this.#log.debug('onDestroy called');
});
}

get user() {
return readonly(this.#user);
}

async isAuthenticatedAsync(): Promise<boolean> {
return await this.auth.isAuthenticatedAsync();
}

/**
* elevate if neededelevateEmailSecurityKey
* @returns error
*/
async elevate() {
const $elevated = get(this.elevated);
const $user = get(this.#user);
if (!$elevated && $user?.id && (await hasSecurityKey($user.id))) {
const { error } = await this.auth.elevateEmailSecurityKey($user?.email as string);
if (error) return error;
}
return null;
}
}

// this is important if u are gonna have any SSR
// https://www.youtube.com/watch?v=EyDV5XLfagg
// https://kit.svelte.dev/docs/state-management

const NHOST_CLIENT_KEY = Symbol('NHOST_CLIENT');

/**
* We should not have exported state like this, to prevent:
* Session state shared between different users when rendered on server(SSR), but don't worry,
* This custom `nhost` object will not have sensitive data on server-side,
* as we carefully check if this code is running in the browser, then only set session data.
*/
// export let nhost: SvelteKitNhostClient;

export const setNhostClient = () => {
const nhost = new SvelteKitNhostClient({
// subdomain: PUBLIC_NHOST_SUBDOMAIN || 'local',
// region: PUBLIC_NHOST_REGION,
authUrl: env.PUBLIC_NHOST_AUTH_URL,
graphqlUrl: env.PUBLIC_NHOST_GRAPHQL_URL,
storageUrl: env.PUBLIC_NHOST_STORAGE_URL,
functionsUrl: env.PUBLIC_NHOST_FUNCTIONS_URL,
});
return setContext(NHOST_CLIENT_KEY, nhost);
};

export const getNhostClient = () => {
return getContext<ReturnType<typeof setNhostClient>>(NHOST_CLIENT_KEY);
};
5 changes: 3 additions & 2 deletions apps/console/src/routes/(app)/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import WaitForAuth from '$lib/components/layout/wait-for-auth.svelte';
import { isAuthenticated, nhost } from '$lib/stores/user';
import { getNhostClient } from '$lib/stores/nhost';
import { onMount } from 'svelte';
export let data;
const nhost = getNhostClient();
/**
* Ensure that Auth is initialized before rendering the app.
* Otherwise all authorized graphql queries will get errors.
*/
onMount(async () => {
if (!(await nhost.auth.isAuthenticated())) {
if (!nhost.auth.isAuthenticated()) {
goto(`/signin?redirectTo=${$page.url.pathname}`);
}
});
Expand Down
2 changes: 1 addition & 1 deletion apps/console/src/routes/(app)/policies/+page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const load = async (event) => {
const form = await superValidate(url, zod(schema));

if (!form.valid) return { status: 400, form }; // return fail(400, { form }); // FIXME
await sleep(5000);
await sleep(1000);

const {
data: { limit, offset, subjectType, subjectId },
Expand Down
8 changes: 4 additions & 4 deletions apps/console/src/routes/(app)/profile/+page.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { load_GetUser } from '$houdini';
import { user } from '$lib/stores/user';
import { Logger } from '@spectacular/utils';
import { error } from '@sveltejs/kit';
import { get } from 'svelte/store';
import type { PageLoad, GetUserVariables as Variables } from './$houdini';

const log = new Logger('user.profile.browser');

// export const _GetUserVariables: Variables = async (event) => {
// const userId = get(user)?.id;
// const { user } = nhost;
// const userId = get(user)?.id;
// if (!userId) {
// log.error('not authenticated');
// throw error(400, 'not authenticated');
Expand All @@ -20,7 +19,8 @@ const log = new Logger('user.profile.browser');
* TODO: is this going to be a blocking call?
*/
export const load: PageLoad = async (event) => {
const userId = get(user)?.id;
const { session } = await event.parent();
const userId = session?.user.id;
if (!userId) {
log.error('not authenticated');
throw error(400, 'not authenticated');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { handleMessage } from '$lib/components/layout/toast-manager';
import { type ChangeEmail, changeEmailSchema } from '$lib/schema/user';
import { getLoadingState } from '$lib/stores/loading';
import { nhost } from '$lib/stores/user';
import { getNhostClient } from '$lib/stores/nhost';
import { getToastStore } from '@skeletonlabs/skeleton';
import { DebugShell } from '@spectacular/skeleton';
import { Button } from '@spectacular/skeleton/components/button';
Expand All @@ -17,6 +17,7 @@ export let initialData: ChangeEmail;
const log = new Logger('profile:password:browser');
const toastStore = getToastStore();
const loadingState = getLoadingState();
const { nhost } = getNhostClient();
const form = superForm(defaults(initialData, zod(changeEmailSchema)), {
SPA: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { handleMessage } from '$lib/components/layout/toast-manager';
import { changePasswordSchema } from '$lib/schema/user';
import { getLoadingState } from '$lib/stores/loading';
import { nhost } from '$lib/stores/user';
import { getNhostClient } from '$lib/stores/nhost';
import { getToastStore } from '@skeletonlabs/skeleton';
import { DebugShell } from '@spectacular/skeleton';
import { Alerts } from '@spectacular/skeleton/components/form';
Expand All @@ -15,6 +15,7 @@ import { zod, zodClient } from 'sveltekit-superforms/adapters';
const log = new Logger('profile:password:browser');
const toastStore = getToastStore();
const loadingState = getLoadingState();
const { nhost } = getNhostClient();
const form = superForm(defaults(zod(changePasswordSchema)), {
SPA: true,
Expand Down
Loading

0 comments on commit 69fb6ae

Please sign in to comment.