Skip to content

Commit

Permalink
Merge pull request #251 from codex-team/auth-guard
Browse files Browse the repository at this point in the history
feat(auth): Implemented authRequired decoration for routes
  • Loading branch information
e11sy authored Jul 10, 2024
2 parents df8a51e + 2cdbbc9 commit c450408
Show file tree
Hide file tree
Showing 15 changed files with 189 additions and 28 deletions.
6 changes: 6 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,18 @@
import Header from '@/presentation/components/header/Header.vue';
import { onErrorCaptured } from 'vue';
import { useTheme, Popover } from 'codex-ui/vue';
import useAuthRequired from '@/application/services/useAuthRequired';
/**
* Read theme from local storage and apply it
*/
useTheme();
/**
* Check for authorization on appropriate routes
*/
useAuthRequired();
/**
* All errors inside the application
*/
Expand Down
6 changes: 5 additions & 1 deletion src/application/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@
"500": "Unknown error happened",
"default": "Something went wrong"
},
"authorize" : {
"message" : "Authorization required"
},
"join": {
"title": "Joining a note team...",
"messages": {
Expand Down Expand Up @@ -162,6 +165,7 @@
"marketplace": "Marketplace",
"addTool": "Add tool",
"notFound": "Not found",
"joinTeam": "Join"
"joinTeam": "Join",
"authorization": "Authorize"
}
}
19 changes: 14 additions & 5 deletions src/application/router/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Settings from '@/presentation/pages/Settings.vue';
import NoteSettings from '@/presentation/pages/NoteSettings.vue';
import ErrorPage from '@/presentation/pages/Error.vue';
import JoinPage from '@/presentation/pages/Join.vue';
import AuthorizationPage from '@/presentation/pages/AuthorizationPage.vue';
import type { RouteRecordRaw } from 'vue-router';
import AddTool from '@/presentation/pages/marketplace/AddTool.vue';
import MarketplacePage from '@/presentation/pages/marketplace/MarketplacePage.vue';
Expand Down Expand Up @@ -52,6 +53,7 @@ const routes: RouteRecordRaw[] = [
meta: {
pageTitleI18n: 'pages.newNote',
discardTabOnLeave: true,
authRequired: true,
layout: 'fullpage',
},
},
Expand All @@ -65,6 +67,7 @@ const routes: RouteRecordRaw[] = [
meta: {
pageTitleI18n: 'pages.newNote',
discardTabOnLeave: true,
authRequired: true,
},
},
{
Expand All @@ -75,10 +78,12 @@ const routes: RouteRecordRaw[] = [
},
},
{
name: 'settings',
path: `/settings/`,
component: Settings,
meta: {
pageTitleI18n: 'pages.userSettings',
authRequired: true,
},
},
{
Expand All @@ -90,6 +95,7 @@ const routes: RouteRecordRaw[] = [
}),
meta: {
pageTitleI18n: 'pages.noteSettings',
authRequired: true,
},
},
{
Expand All @@ -106,6 +112,7 @@ const routes: RouteRecordRaw[] = [
component: MarketplaceTools,
meta: {
pageTitleI18n: 'pages.marketplace',
authRequired: true,
},
},
{
Expand All @@ -114,6 +121,7 @@ const routes: RouteRecordRaw[] = [
component: AddTool,
meta: {
pageTitleI18n: 'pages.addTool',
authRequired: true,
},
}],
},
Expand All @@ -127,17 +135,18 @@ const routes: RouteRecordRaw[] = [
meta: {
pageTitleI18n: 'pages.joinTeam',
discardTabOnLeave: true,
authRequired: true,
},
},
{
name: 'join',
path: '/join/:hash',
component: JoinPage,
name: 'authorization',
path: '/auth',
component: AuthorizationPage,
props: route => ({
invitationHash: String(route.params.hash),
redirect: String(route.query.redirect),
}),
meta: {
pageTitleI18n: 'pages.joinTeam',
pageTitleI18n: 'pages.authorization',
discardTabOnLeave: true,
},
},
Expand Down
6 changes: 4 additions & 2 deletions src/application/services/useAppState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import { type Ref, ref } from 'vue';
interface UseAppStateComposable {
/**
* Current authenticated user
* User is undefined if authorization is in process
* User is null if is not authorized, User instance otherwise
*/
user: Ref<User | null>;
user: Ref<User | null | undefined>;

/**
* User editor tools that are used in notes creation.
Expand All @@ -27,7 +29,7 @@ export const useAppState = createSharedComposable((): UseAppStateComposable => {
/**
* Current authenticated user
*/
const user = ref<User | null>(null);
const user = ref<User | null | undefined>(undefined);

/**
* User editor tools that are used in notes creation
Expand Down
40 changes: 40 additions & 0 deletions src/application/services/useAuthRequired.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useAppState } from './useAppState.ts';
import useAuth from './useAuth.ts';
import { useRouter } from 'vue-router';

/**
* Function that is used in App for checking user authorization
* Works only for routes with authRequired set to true in route.meta
* If user is not authorized will show auth popup
*/
export default function useAuthRequired(): void {
const { showGoogleAuthPopup } = useAuth();
const router = useRouter();
const { user } = useAppState();

/**
* Check if user is authorized
* If authorization is in process we treat user as unauthorized
* When oauth will work, it will be treated as he authorized manually
* @returns true if user is authorized, false otherwise
*/
function isUserAuthorized(): boolean {
return (user.value !== null && user.value !== undefined);
}

/**
* For each route check if auth is required
*/
router.beforeEach((actualRoute, _, next) => {
if (actualRoute.meta.authRequired === true && !isUserAuthorized()) {
/**
* If auth is required and user is not autorized
* Then show google auth popup and redirect user to auth page
*/
showGoogleAuthPopup();
next(`/auth?redirect=${actualRoute.fullPath?.toString()}`);
} else {
next();
}
});
}
2 changes: 1 addition & 1 deletion src/application/services/useHeader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export default function useHeader(): useHeaderComposableState {
/**
* Hook for adding new page to storage when user changes route
*/
router.beforeEach((currentRoute, prevRoute) => {
router.beforeResolve((currentRoute, prevRoute) => {
/**
* If we are created new note we should replace 'New Note' tab with tab with actual note title
*/
Expand Down
2 changes: 1 addition & 1 deletion src/domain/user.repository.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default interface UserRepositoryInterface {
/**
* Removes user data from the storage
*/
removeUser: () => void;
clearUser: () => void;

/**
* Loads and store editor tools from user extensions
Expand Down
2 changes: 1 addition & 1 deletion src/domain/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export default class UserService {
* When we got unauthorized
*/
eventBus.addEventListener(AUTH_LOGOUT_EVENT_NAME, () => {
void this.repository.removeUser();
void this.repository.clearUser();
});
}

Expand Down
2 changes: 2 additions & 0 deletions src/infrastructure/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ export function init(noteApiUrl: string, eventBus: EventBus): Repositories {
* Tell API transport to continue working in anonymous mode (send waiting requests without auth)
*/
notesApiTransport.continueAnonymous();

userStore.clearUser();
}
});
/**
Expand Down
54 changes: 47 additions & 7 deletions src/infrastructure/storage/abstract/subscribable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
*/
export type PropChangeCallback<StoreData> = (prop: keyof StoreData, newValue: StoreData[typeof prop]) => void;

/**
* Type represents one change that has been commited to stored data
*/
type Change<StoreData> = {
/**
* Property of stored data which was changed
*/
prop: keyof StoreData;

/**
* New value of the stored data property
*/
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
newValue: any;
};

/**
* Base class for subscribable stores.
* Allows to subscribe to store data changes
Expand All @@ -19,6 +35,11 @@ export abstract class SubscribableStore<StoreData extends Record<string, unknown
*/
private subscribers: PropChangeCallback<StoreData>[] = [];

/**
* Using for accumulation of changes in store until subscriber appearse
*/
private stashedChanges: Change<StoreData>[] = [];

/**
* Data proxy handler
*/
Expand All @@ -27,7 +48,8 @@ export abstract class SubscribableStore<StoreData extends Record<string, unknown
set: (target, prop, value, receiver) => {
Reflect.set(target, prop, value, receiver);

this.onDataChange(prop as keyof StoreData, value);
this.onDataChange([{ prop: prop as keyof StoreData,
newValue: value }]);

return true;
},
Expand All @@ -43,6 +65,11 @@ export abstract class SubscribableStore<StoreData extends Record<string, unknown
*/
public subscribe(callback: PropChangeCallback<StoreData>): void {
this.subscribers.push(callback);

/**
* When new service subscribed we should develop all stashed changes
*/
this.onDataChange(this.stashedChanges);
}

/**
Expand All @@ -57,12 +84,25 @@ export abstract class SubscribableStore<StoreData extends Record<string, unknown
/**
* Function called when store data is changed.
* Notifies subscribers about data change.
* @param prop - changed property
* @param newValue - new value of changed property
* @param changes - array of changes
*/
private onDataChange(prop: keyof StoreData, newValue: StoreData[typeof prop]): void {
this.subscribers.forEach((callback) => {
callback(prop, newValue);
});
private onDataChange(changes: Change<StoreData>[]): void {
/**
* If there are no subscribers stash current change
*/
if (this.subscribers.length === 0) {
this.stashedChanges.push(changes[0]);
} else {
this.subscribers.forEach((callback) => {
changes.forEach((change) => {
callback(change.prop, change.newValue);
});
});

/**
* Clear stashed changes
*/
this.stashedChanges = [];
}
}
}
2 changes: 1 addition & 1 deletion src/infrastructure/storage/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class UserStore extends SubscribableStore<UserStoreData> {
/**
* Removes user data
*/
public removeUser(): void {
public clearUser(): void {
this.data.user = null;
}

Expand Down
4 changes: 2 additions & 2 deletions src/infrastructure/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ export default class UserRepository extends Repository<UserStore, UserStoreData>
/**
* Removes user data from the storage
*/
public removeUser(): void {
this.store.removeUser();
public clearUser(): void {
this.store.clearUser();

return;
}
Expand Down
53 changes: 53 additions & 0 deletions src/presentation/pages/AuthorizationPage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<template>
<div :class="$style['message']">
{{ t('authorize.message') }}
<Button
@click="showGoogleAuthPopup"
>
{{ t('auth.login') }}
</Button>
</div>
</template>

<script setup lang="ts">
import { useAppState } from '@/application/services/useAppState';
import useAuth from '@/application/services/useAuth';
import { useI18n } from 'vue-i18n';
import { watch } from 'vue';
import { useRouter } from 'vue-router';
import { Button } from 'codex-ui/vue';
const { user } = useAppState();
const { showGoogleAuthPopup } = useAuth();
const { t } = useI18n();
const router = useRouter();
const props = defineProps<{
/**
* Link to auth guarded page
* If user would authorized he will be redirected via this link
*/
redirect: string;
}>();
/**
* Checks status of user authorization
* If user had been authorized, then redirects to page that he wanted to visit
*/
watch(user, () => {
if (user.value !== null && user.value !== undefined) {
router.push(props.redirect);
}
});
</script>

<style lang="postcss" module>
.message {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
flex-direction: column;
gap: var(--spacing-l)
}
</style>
Loading

0 comments on commit c450408

Please sign in to comment.