Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(console): using svelte context managed stores #201

Merged
merged 5 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused variable log.

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';

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused imports PUBLIC_NHOST_REGION, PUBLIC_NHOST_SUBDOMAIN.
import { extractSession, getClientSession, setClientSession } from '$houdini';

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused imports extractSession, getClientSession.
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
Loading