BLOG-136 Dashboard route and frontend authentication #137

Merged
squid merged 10 commits from BLOG-136_create_dashboard_route into main 2025-10-13 20:33:36 +08:00
22 changed files with 307 additions and 13 deletions
Showing only changes of commit 365c979878 - Show all commits

View File

@ -1 +1 @@
PUBLIC_API_BASE_URL=http://127.0.0.1:5173/api/
PUBLIC_API_BASE_URL=http://localhost:5173/api/

View File

@ -5,6 +5,7 @@ declare global {
// interface Error {}
interface Locals {
authBloc: import('$lib/auth/adapter/presenter/authBloc').AuthBloc;
postListBloc: import('$lib/post/adapter/presenter/postListBloc').PostListBloc;
postBloc: import('$lib/post/adapter/presenter/postBloc').PostBloc;
}

View File

@ -8,6 +8,14 @@ import { GetPostUseCase } from '$lib/post/application/useCase/getPostUseCase';
import { PostApiServiceImpl } from '$lib/post/framework/api/postApiServiceImpl';
import type { Handle } from '@sveltejs/kit';
import { Environment } from '$lib/environment';
import { AuthApiServiceImpl } from '$lib/auth/framework/api/authApiServiceImpl';
import { AuthRepositoryImpl } from '$lib/auth/adapter/gateway/authRepositoryImpl';
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService';
import type { AuthRepository } from '$lib/auth/application/gateway/authRepository';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
import { AuthBloc } from '$lib/auth/adapter/presenter/authBloc';
import { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase';
Sentry.init({
dsn: Environment.SENTRY_DSN,
@ -16,11 +24,16 @@ Sentry.init({
});
export const handle: Handle = sequence(Sentry.sentryHandle(), ({ event, resolve }) => {
const postApiService = new PostApiServiceImpl(event.fetch);
const postRepository = new PostRepositoryImpl(postApiService);
const authApiService: AuthApiService = new AuthApiServiceImpl(event.fetch);
const authRepository: AuthRepository = new AuthRepositoryImpl(authApiService);
const getCurrentUserUseCase = new GetCurrentUserUseCase(authRepository);
const postApiService: PostApiService = new PostApiServiceImpl(event.fetch);
const postRepository: PostRepository = new PostRepositoryImpl(postApiService);
const getAllPostsUseCase = new GetAllPostsUseCase(postRepository);
const getPostUseCase = new GetPostUseCase(postRepository);
event.locals.authBloc = new AuthBloc(getCurrentUserUseCase);
event.locals.postListBloc = new PostListBloc(getAllPostsUseCase);
event.locals.postBloc = new PostBloc(getPostUseCase);

View File

@ -0,0 +1,5 @@
import type { UserResponseDto } from '$lib/auth/adapter/gateway/userResponseDto';
export interface AuthApiService {
getCurrentUser(): Promise<UserResponseDto | null>;
}

View File

@ -0,0 +1,12 @@
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService';
import type { AuthRepository } from '$lib/auth/application/gateway/authRepository';
import type { User } from '$lib/auth/domain/entity/user';
export class AuthRepositoryImpl implements AuthRepository {
constructor(private readonly authApiService: AuthApiService) {}
async getCurrentUser(): Promise<User | null> {
const result = await this.authApiService.getCurrentUser();
return result?.toEntity() ?? null;
}
}

View File

@ -0,0 +1,37 @@
import { User } from '$lib/auth/domain/entity/user';
import z from 'zod';
export const UserResponseSchema = z.object({
id: z.int32(),
displayed_name: z.string(),
email: z.email(),
});
export class UserResponseDto {
readonly id: number;
readonly displayedName: string;
readonly email: string;
private constructor(props: { id: number; displayedName: string; email: string }) {
this.id = props.id;
this.displayedName = props.displayedName;
this.email = props.email;
}
static fromJson(json: unknown): UserResponseDto {
const parsedJson = UserResponseSchema.parse(json);
return new UserResponseDto({
id: parsedJson.id,
displayedName: parsedJson.displayed_name,
email: parsedJson.email,
});
}
toEntity(): User {
return new User({
id: this.id,
name: this.displayedName,
email: this.email,
});
}
}

View File

@ -0,0 +1,59 @@
import { AuthViewModel } from '$lib/auth/adapter/presenter/authViewModel';
import { UserViewModel } from '$lib/auth/adapter/presenter/userViewModel';
import type { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase';
import { StatusType, type AsyncState } from '$lib/common/adapter/presenter/asyncState';
import { get, writable } from 'svelte/store';
export type AuthState = AsyncState<AuthViewModel>;
export type AuthEvent = CurrentUserLoadedEvent;
export class AuthBloc {
private readonly state = writable<AuthState>({
status: StatusType.Idle,
});
constructor(
private readonly getCurrentUserUseCase: GetCurrentUserUseCase,
initialData?: AuthViewModel
) {
this.state.set({
status: StatusType.Idle,
data: initialData,
});
}
get subscribe() {
return this.state.subscribe;
}
async dispatch(event: AuthEvent): Promise<AuthState> {
switch (event.event) {
case AuthEventType.CurrentUserLoadedEvent:
return this.loadCurrentUser();
}
}
private async loadCurrentUser(): Promise<AuthState> {
this.state.set({ status: StatusType.Loading, data: get(this.state).data });
const user = await this.getCurrentUserUseCase.execute();
const userViewModel = user ? UserViewModel.fromEntity(user) : null;
const authViewModel = AuthViewModel.fromEntity(userViewModel);
const result: AuthState = {
status: StatusType.Success,
data: authViewModel,
};
this.state.set(result);
return result;
}
}
export enum AuthEventType {
CurrentUserLoadedEvent,
}
interface CurrentUserLoadedEvent {
event: AuthEventType.CurrentUserLoadedEvent;
}

View File

@ -0,0 +1,33 @@
import { UserViewModel, type DehydratedUserProps } from '$lib/auth/adapter/presenter/userViewModel';
export class AuthViewModel {
readonly user: UserViewModel | null;
constructor(params: { user: UserViewModel | null }) {
this.user = params.user;
}
static fromEntity(user: UserViewModel | null): AuthViewModel {
return new AuthViewModel({ user });
}
static rehydrate(props: DehydratedAuthProps): AuthViewModel {
return new AuthViewModel({
user: props.user ? UserViewModel.rehydrate(props.user) : null,
});
}
dehydrate(): DehydratedAuthProps {
return {
user: this.user ? this.user.dehydrate() : null,
};
}
get isAuthenticated(): boolean {
return this.user !== null;
}
}
export interface DehydratedAuthProps {
user: DehydratedUserProps | null;
}

View File

@ -0,0 +1,43 @@
import type { User } from '$lib/auth/domain/entity/user';
export class UserViewModel {
readonly id: number;
readonly name: string;
readonly email: string;
private constructor(props: { id: number; name: string; email: string }) {
this.id = props.id;
this.name = props.name;
this.email = props.email;
}
static fromEntity(user: User): UserViewModel {
return new UserViewModel({
id: user.id,
name: user.name,
email: user.email,
});
}
static rehydrate(props: DehydratedUserProps): UserViewModel {
return new UserViewModel({
id: props.id,
name: props.name,
email: props.email,
});
}
dehydrate(): DehydratedUserProps {
return {
id: this.id,
name: this.name,
email: this.email,
};
}
}
export interface DehydratedUserProps {
id: number;
name: string;
email: string;
}

View File

@ -0,0 +1,5 @@
import type { User } from '$lib/auth/domain/entity/user';
export interface AuthRepository {
getCurrentUser(): Promise<User | null>;
}

View File

@ -0,0 +1,10 @@
import type { AuthRepository } from '$lib/auth/application/gateway/authRepository';
import type { User } from '$lib/auth/domain/entity/user';
export class GetCurrentUserUseCase {
constructor(private readonly authRepository: AuthRepository) {}
async execute(): Promise<User | null> {
return await this.authRepository.getCurrentUser();
}
}

View File

@ -0,0 +1,11 @@
export class User {
id: number;
name: string;
email: string;
constructor(props: { id: number; name: string; email: string }) {
this.id = props.id;
this.name = props.name;
this.email = props.email;
}
}

View File

@ -0,0 +1,20 @@
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService';
import { UserResponseDto } from '$lib/auth/adapter/gateway/userResponseDto';
import { Environment } from '$lib/environment';
export class AuthApiServiceImpl implements AuthApiService {
constructor(private readonly fetchFn: typeof fetch) {}
async getCurrentUser(): Promise<UserResponseDto | null> {
const url = new URL('me', Environment.API_BASE_URL);
const response = await this.fetchFn(url);
if (!response.ok) {
return null;
}
const json = await response.json();
return UserResponseDto.fromJson(json);
}
}

View File

@ -1,5 +1,5 @@
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/repository/postRepository';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
import type { Post } from '$lib/post/domain/entity/post';
import type { PostInfo } from '$lib/post/domain/entity/postInfo';

View File

@ -1,4 +1,4 @@
import type { PostRepository } from '$lib/post/application/repository/postRepository';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
import type { PostInfo } from '$lib/post/domain/entity/postInfo';
export class GetAllPostsUseCase {

View File

@ -1,4 +1,4 @@
import type { PostRepository } from '$lib/post/application/repository/postRepository';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
import type { Post } from '$lib/post/domain/entity/post';
export class GetPostUseCase {

View File

@ -4,12 +4,12 @@ import { PostInfoResponseDto } from '$lib/post/adapter/gateway/postInfoResponseD
import { PostResponseDto } from '$lib/post/adapter/gateway/postResponseDto';
export class PostApiServiceImpl implements PostApiService {
constructor(private fetchFn: typeof fetch) {}
constructor(private readonly fetchFn: typeof fetch) {}
async getAllPosts(): Promise<PostInfoResponseDto[]> {
const url = new URL('post', Environment.API_BASE_URL);
const response = await this.fetchFn(url.href);
const response = await this.fetchFn(url);
if (!response.ok) {
return [];
@ -22,7 +22,7 @@ export class PostApiServiceImpl implements PostApiService {
async getPost(id: string): Promise<PostResponseDto | null> {
const url = new URL(`post/${id}`, Environment.API_BASE_URL);
const response = await this.fetchFn(url.href);
const response = await this.fetchFn(url);
if (!response.ok) {
return null;

View File

@ -0,0 +1,40 @@
<script lang="ts">
import type { AuthApiService } from '$lib/auth/adapter/gateway/authApiService';
import { AuthRepositoryImpl } from '$lib/auth/adapter/gateway/authRepositoryImpl';
import { AuthBloc, AuthEventType } from '$lib/auth/adapter/presenter/authBloc';
import type { AuthRepository } from '$lib/auth/application/gateway/authRepository';
import { GetCurrentUserUseCase } from '$lib/auth/application/useCase/getCurrentUserUseCase';
import { AuthApiServiceImpl } from '$lib/auth/framework/api/authApiServiceImpl';
import { onMount, setContext } from 'svelte';
import type { LayoutProps } from './$types';
import { StatusType } from '$lib/common/adapter/presenter/asyncState';
const { children }: LayoutProps = $props();
const authApiService: AuthApiService = new AuthApiServiceImpl(fetch);
const authRepository: AuthRepository = new AuthRepositoryImpl(authApiService);
const getcurrentUserUseCase = new GetCurrentUserUseCase(authRepository);
const authBloc = new AuthBloc(getcurrentUserUseCase);
setContext(AuthBloc.name, authBloc);
const authState = $derived($authBloc);
onMount(() => authBloc.dispatch({ event: AuthEventType.CurrentUserLoadedEvent }));
const hasError = $derived.by(() => {
if (authState.status === StatusType.Error) {
return true;
}
if (authState.status === StatusType.Success && !authState.data.isAuthenticated) {
return true;
}
return false;
});
</script>
{#if hasError}
<div>Error</div>
{:else}
{@render children()}
{/if}

View File

@ -0,0 +1 @@
<div>Dashboard</div>

View File

@ -7,13 +7,15 @@
import type { PageProps } from './$types';
import { PostInfoViewModel } from '$lib/post/adapter/presenter/postInfoViewModel';
import PostOverallPage from '$lib/post/framework/ui/PostOverallPage.svelte';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
let { data }: PageProps = $props();
const initialData = data.dehydratedData?.map((post) => PostInfoViewModel.rehydrate(post));
const postApiService = new PostApiServiceImpl(fetch);
const postRepository = new PostRepositoryImpl(postApiService);
const postApiService: PostApiService = new PostApiServiceImpl(fetch);
const postRepository: PostRepository = new PostRepositoryImpl(postApiService);
const getAllPostsUseCase = new GetAllPostsUseCase(postRepository);
const postListBloc = new PostListBloc(getAllPostsUseCase, initialData);

View File

@ -7,14 +7,16 @@
import { setContext } from 'svelte';
import type { PageProps } from './$types';
import PostContentPage from '$lib/post/framework/ui/PostContentPage.svelte';
import type { PostApiService } from '$lib/post/adapter/gateway/postApiService';
import type { PostRepository } from '$lib/post/application/gateway/postRepository';
const { data, params }: PageProps = $props();
const { id } = params;
const initialData = PostViewModel.rehydrate(data.dehydratedData!);
const postApiService = new PostApiServiceImpl(fetch);
const postRepository = new PostRepositoryImpl(postApiService);
const postApiService: PostApiService = new PostApiServiceImpl(fetch);
const postRepository: PostRepository = new PostRepositoryImpl(postApiService);
const getPostUseCase = new GetPostUseCase(postRepository);
const postBloc = new PostBloc(getPostUseCase, initialData);