diff --git a/apps/trpc-app-e2e-playwright/tests/app.spec.ts b/apps/trpc-app-e2e-playwright/tests/app.spec.ts index dcb97fdde..859520e75 100644 --- a/apps/trpc-app-e2e-playwright/tests/app.spec.ts +++ b/apps/trpc-app-e2e-playwright/tests/app.spec.ts @@ -43,14 +43,25 @@ describe('tRPC Demo App', () => { ).toContain(/Analog + tRPC/i); }); - test(`If user enters the first note the note should be stored - successfully and listed in the notes array`, async (ctx) => { + test(` + If user enters the first note the note should be storedsuccessfully and listed in the notes array. + Still unauthorized the user should not be able to delete the note and the error should be displayed. + After the users clicks the Login button and gets authorized, deleting the note again should work successfully, + and the error should disappear. + `, async (ctx) => { await ctx.notesPage.typeNote(notes.first.note); await ctx.notesPage.addNote(); expect(await ctx.notesPage.notes().elementHandles()).toHaveLength(1); await ctx.notesPage.removeNote(0); + expect(await ctx.notesPage.notes().elementHandles()).toHaveLength(1); + expect(await ctx.notesPage.getDeleteErrorCount()).toBe(1); + + await ctx.notesPage.toggleLogin(); + await ctx.notesPage.removeNote(0); + await page.waitForSelector('.no-notes'); expect(await ctx.notesPage.notes().elementHandles()).toHaveLength(0); + expect(await ctx.notesPage.getDeleteErrorCount()).toBe(0); }); }); diff --git a/apps/trpc-app-e2e-playwright/tests/fixtures/notes.po.ts b/apps/trpc-app-e2e-playwright/tests/fixtures/notes.po.ts index 4f54c8398..0ac388f5e 100644 --- a/apps/trpc-app-e2e-playwright/tests/fixtures/notes.po.ts +++ b/apps/trpc-app-e2e-playwright/tests/fixtures/notes.po.ts @@ -3,6 +3,10 @@ import { Page } from 'playwright'; export class NotesPage { constructor(readonly page: Page) {} + async toggleLogin() { + await this.page.getByTestId('loginBtn').click(); + } + async typeNote(note: string) { await this.page.getByTestId('newNoteInput').fill(note); } @@ -16,7 +20,10 @@ export class NotesPage { await this.waitForTrpcResponse( this.page.getByTestId('removeNoteAtIndexBtn' + index).click() ); - await this.page.waitForSelector('.no-notes'); + } + + async getDeleteErrorCount() { + return this.page.locator('[data-testid="deleteError"]').count(); } notes() { diff --git a/apps/trpc-app/src/app/pages/index.page.ts b/apps/trpc-app/src/app/pages/index.page.ts index 1b756cf7d..7cb81d5d8 100644 --- a/apps/trpc-app/src/app/pages/index.page.ts +++ b/apps/trpc-app/src/app/pages/index.page.ts @@ -1,10 +1,17 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { injectTRPCClient } from '../../trpc-client'; +import { + ChangeDetectionStrategy, + Component, + effect, + signal, +} from '@angular/core'; +import { injectTRPCClient, tRPCHeaders } from '../../trpc-client'; import { AsyncPipe, DatePipe, JsonPipe, NgFor, NgIf } from '@angular/common'; import { FormsModule, NgForm } from '@angular/forms'; import { Note } from '../../note'; -import { shareReplay, Subject, switchMap, take } from 'rxjs'; +import { catchError, of, shareReplay, Subject, switchMap, take } from 'rxjs'; import { waitFor } from '@analogjs/trpc'; +import { TRPCClientError } from '@trpc/client'; +import { AppRouter } from '../../server/trpc/routers'; const inputTw = 'focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:outline-0 block w-full appearance-none rounded-lg px-3 py-2 transition-colors text-base leading-tight md:text-sm bg-black/[.05] dark:bg-zinc-50/10 focus:bg-white dark:focus:bg-dark placeholder:text-zinc-500 dark:placeholder:text-zinc-400 contrast-more:border contrast-more:border-current'; @@ -28,6 +35,9 @@ const btnTw = src="/assets/spartan.svg" /> +
+

+ {{ error()?.message }} +

`, }) export default class HomeComponent { @@ -99,10 +112,23 @@ export default class HomeComponent { shareReplay(1) ); public newNote = ''; + public loggedIn = signal(false); + public error = signal | undefined>(undefined); constructor() { void waitFor(this.notes$); this.triggerRefresh$.next(); + + effect( + () => + tRPCHeaders.mutate( + (h) => + (h['authorization'] = this.loggedIn() + ? 'Bearer authToken' + : undefined) + ), + { allowSignalWrites: true } + ); } public noteTrackBy = (index: number, note: Note) => { @@ -114,6 +140,7 @@ export default class HomeComponent { form.form.markAllAsTouched(); return; } + console.log(tRPCHeaders()); this._trpc.note.create .mutate({ title: this.newNote }) .pipe(take(1)) @@ -123,9 +150,20 @@ export default class HomeComponent { } public removePost(id: number) { + this.error.set(undefined); this._trpc.note.remove .mutate({ id }) - .pipe(take(1)) + .pipe( + take(1), + catchError((e) => { + this.error.set(e); + return of(null); + }) + ) .subscribe(() => this.triggerRefresh$.next()); } + + public toggleLogin() { + this.loggedIn.update((loggedIn) => !loggedIn); + } } diff --git a/apps/trpc-app/src/server/trpc/context.ts b/apps/trpc-app/src/server/trpc/context.ts index d7da5d9d5..a9f14757b 100644 --- a/apps/trpc-app/src/server/trpc/context.ts +++ b/apps/trpc-app/src/server/trpc/context.ts @@ -1,8 +1,16 @@ import { inferAsyncReturnType } from '@trpc/server'; +import { getRequestHeader, H3Event } from 'h3'; /** * Creates context for an incoming request * @link https://trpc.io/docs/context */ -export const createContext = () => ({}); +export const createContext = async (event: H3Event) => { + // Create your context based on the request object + // Will be available as `ctx` in all your resolvers + const authorization = getRequestHeader(event, 'authorization'); + return { + hasAuth: authorization && authorization.split(' ')[1]?.length > 0, + }; +}; export type Context = inferAsyncReturnType; diff --git a/apps/trpc-app/src/server/trpc/routers/notes.ts b/apps/trpc-app/src/server/trpc/routers/notes.ts index 1b890989c..fd3e7a6f8 100644 --- a/apps/trpc-app/src/server/trpc/routers/notes.ts +++ b/apps/trpc-app/src/server/trpc/routers/notes.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { publicProcedure, router } from '../trpc'; +import { protectedProcedure, publicProcedure, router } from '../trpc'; import { Note } from '../../../note'; let noteId = 0; @@ -19,7 +19,7 @@ export const noteRouter = router({ }) ), list: publicProcedure.query(() => notes), - remove: publicProcedure + remove: protectedProcedure .input( z.object({ id: z.number(), diff --git a/apps/trpc-app/src/server/trpc/trpc.ts b/apps/trpc-app/src/server/trpc/trpc.ts index 9812db699..648467e54 100644 --- a/apps/trpc-app/src/server/trpc/trpc.ts +++ b/apps/trpc-app/src/server/trpc/trpc.ts @@ -1,4 +1,4 @@ -import { initTRPC } from '@trpc/server'; +import { initTRPC, TRPCError } from '@trpc/server'; import { Context } from './context'; import superjson from 'superjson'; @@ -9,5 +9,16 @@ const t = initTRPC.context().create({ * Unprotected procedure **/ export const publicProcedure = t.procedure; + +const isAuthed = t.middleware(({ next, ctx }) => { + if (!ctx.hasAuth) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + return next({ + ctx, + }); +}); + +export const protectedProcedure = t.procedure.use(isAuthed); export const router = t.router; export const middleware = t.middleware; diff --git a/apps/trpc-app/src/trpc-client.ts b/apps/trpc-app/src/trpc-client.ts index 7fc94810f..532ba41a0 100644 --- a/apps/trpc-app/src/trpc-client.ts +++ b/apps/trpc-app/src/trpc-client.ts @@ -3,12 +3,13 @@ import { createTrpcClient } from '@analogjs/trpc'; import { inject } from '@angular/core'; import superjson from 'superjson'; -export const { provideTRPCClient, tRPCClient } = createTrpcClient({ - url: 'http://localhost:4205/api/trpc', - options: { - transformer: superjson, - }, -}); +export const { provideTRPCClient, tRPCClient, tRPCHeaders } = + createTrpcClient({ + url: 'http://localhost:4205/api/trpc', + options: { + transformer: superjson, + }, + }); export function injectTRPCClient() { return inject(tRPCClient); diff --git a/packages/trpc/src/lib/client/client.ts b/packages/trpc/src/lib/client/client.ts index 617b9205d..5c68c78d5 100644 --- a/packages/trpc/src/lib/client/client.ts +++ b/packages/trpc/src/lib/client/client.ts @@ -1,6 +1,6 @@ -import { InjectionToken, Provider, TransferState } from '@angular/core'; +import { InjectionToken, Provider, signal, TransferState } from '@angular/core'; import 'isomorphic-fetch'; -import { httpBatchLink } from '@trpc/client'; +import { httpBatchLink, HttpBatchLinkOptions } from '@trpc/client'; import { AnyRouter } from '@trpc/server'; import { transferStateLink } from './links/transfer-state-link'; import { @@ -10,10 +10,12 @@ import { } from './cache-state'; import { createTRPCRxJSProxyClient } from './trpc-rxjs-proxy'; import { CreateTRPCClientOptions } from '@trpc/client/src/createTRPCUntypedClient'; +import { HTTPHeaders } from '@trpc/client/src/links/types'; export type TrpcOptions = { url: string; options?: Partial>; + batchLinkOptions?: Omit; }; export type TrpcClient = ReturnType< @@ -25,7 +27,9 @@ const tRPC_INJECTION_TOKEN = new InjectionToken( export const createTrpcClient = ({ url, options, + batchLinkOptions, }: TrpcOptions) => { + const tRPCHeaders = signal({}); const provideTRPCClient = (): Provider[] => [ provideTrpcCacheState(), provideTrpcCacheStateStatusManager(), @@ -40,6 +44,10 @@ export const createTrpcClient = ({ ...(options?.links ?? []), transferStateLink(), httpBatchLink({ + ...(batchLinkOptions ?? {}), + headers() { + return tRPCHeaders(); + }, url: url ?? '', }), ], @@ -51,5 +59,6 @@ export const createTrpcClient = ({ return { tRPCClient: tRPC_INJECTION_TOKEN as InjectionToken>, provideTRPCClient, + tRPCHeaders, }; };