Compare commits

...

52 commits
v1.2.0 ... main

Author SHA1 Message Date
Markus Thielker
d242378bb9
Release v1.3.1 (#100) 2025-03-18 09:20:23 +01:00
Markus Thielker
2bddc56195 Release-v1.3.1: update package version 2025-03-18 09:19:06 +01:00
Markus Thielker
13b9025fbf
N-FIN-98: fix date picker not opening (#99) 2025-03-18 09:07:08 +01:00
Markus Thielker
3ce44a3302 N-FIN-98: remove initialFocus attribute 2025-03-18 09:06:05 +01:00
Markus Thielker
8e94a0bea5 N-FIN-98: update related shadcn components 2025-03-18 09:06:04 +01:00
Markus Thielker
0cd553600a
Release v1.3.0 (#97) 2025-03-16 00:57:12 +01:00
Markus Thielker
fe58b0190b
Release v1.3.0 (#96) 2025-03-16 00:57:02 +01:00
Markus Thielker
5bb0d71836 RELEASE-v1.3.0: update README.md 2025-03-16 00:54:46 +01:00
Markus Thielker
fc1658602a RELEASE-v1.3.0: fix variable naming for clarity 2025-03-16 00:49:50 +01:00
Markus Thielker
76535bed45 RELEASE-v1.3.0: update package version 2025-03-16 00:41:22 +01:00
Markus Thielker
2a6fbfd70c
N-FIN-76: update app icon (#95) 2025-03-15 15:33:36 +01:00
Markus Thielker
13fc8c1e94
N-FIN-93: migrate the @auth0/nextjs-auth0 SDK to v4 (#94) 2025-03-14 13:50:01 +01:00
Markus Thielker
4abe52d4e8 N-FIN-93: update url constants 2025-03-14 13:47:04 +01:00
Markus Thielker
0bb1db9acc N-FIN-93: update session handling in pages 2025-03-14 13:46:18 +01:00
Markus Thielker
576c2b0c0c N-FIN-93: update session handling in actions 2025-03-14 13:46:11 +01:00
Markus Thielker
e38157e604 N-FIN-93: migrate to middleware route handling 2025-03-14 13:45:53 +01:00
Markus Thielker
4720ff553d N-FIN-93: add new dependency 2025-03-14 13:45:08 +01:00
Markus Thielker
fc361f721f N-FIN-76: update app icon 2025-03-13 08:30:49 +01:00
Markus Thielker
f56f466b40
N-FIN-66: fix restarting stopped container (#92) 2025-03-09 06:00:48 +01:00
Markus Thielker
d526ccf5ff N-FIN-66: fix restarting stopped container 2025-03-09 06:00:10 +01:00
Markus Thielker
ed90d66898
N-FIN-90: fix docker build failing (#91) 2025-03-09 05:38:25 +01:00
Markus Thielker
0f2f055a57 N-FIN-90: replace legacy ENV format 2025-03-09 05:34:22 +01:00
Markus Thielker
25793bb7c9 N-FIN-90: update shadcn calendar component to fix build error 2025-03-09 05:34:22 +01:00
Markus Thielker
237131aa11 N-FIN-90: set base image to alpine variant 2025-03-09 05:01:34 +01:00
Markus Thielker
7389b600ec
N-FIN-88: add new GitHub action (#89) 2025-03-09 04:33:14 +01:00
Markus Thielker
33e3b34305 N-FIN-88: add new GitHub action 2025-03-09 04:32:23 +01:00
Markus Thielker
3c3ad5ee38
Add manual execution to action 2025-03-09 04:18:41 +01:00
Markus Thielker
583bc1aa5d
N-FIN-86: fix payor input loses focus on typing (#87)
Fixes #86
2024-12-25 18:00:13 +01:00
Markus Thielker
5d8554068c
N-FIN-86: focus submit button after last input filled 2024-12-25 17:59:09 +01:00
Markus Thielker
ed49ad4ce7
N-FIN-86: fix payor input always focus next 2024-12-25 17:57:00 +01:00
Markus Thielker
4a25a93186
N-FIN-67: refactor system to focus next form node (#85)
Resolves #67
2024-12-25 17:43:46 +01:00
Markus Thielker
803bfc5807
N-FIN-67: refactor system to focus next form node
Also added some documentation of the custom auto-complete input
2024-12-25 17:42:31 +01:00
Markus Thielker
fc0a9abc7b
N-FIN-83: upgrade dependencies (#84)
Resolves #83 

Next.js 15 introduces a new async request api leading to warnings in the
`getSession()` call of the `@auth0/nextjs-auth0` package. The app still
works as intended.
2024-12-24 12:56:26 +01:00
Markus Thielker
91de5a730c
N-FIN-83: run 'next-async-request-api' codemod 2024-12-24 12:50:44 +01:00
Markus Thielker
59007f5973
N-FIN-83: fix serwist configuration 2024-12-24 12:49:47 +01:00
Markus Thielker
5be1e78ddd
N-FIN-83: upgrade dependency versions 2024-12-24 12:49:33 +01:00
Markus Thielker
f24d4e4a38
N-FIN-75: fix category can not be deleted (#82)
Resolves #75
2024-12-23 19:46:05 +01:00
Markus Thielker
4834750659
N-FIN-75: update related payments before deleting category 2024-12-23 19:44:06 +01:00
Markus Thielker
155ab2f2e3
N-FIN-78: replace npm with bun (#81)
Resolves #78
2024-12-23 02:19:15 +01:00
Markus Thielker
021bfcc65d
N-FIN-78: replace npm with bun 2024-12-23 02:18:14 +01:00
Markus Thielker
1aa3ed85c5
N-FIN-79: replace lucia with auth0 (#80)
Resolves #79
2024-12-23 00:32:37 +01:00
Markus Thielker
0e952e4933
N-FIN-79: add Auth0 variable to .env.example 2024-12-23 00:26:41 +01:00
Markus Thielker
f378e2a045
N-FIN-79: refactor account data reset 2024-12-23 00:18:05 +01:00
Markus Thielker
98f29a8366
N-FIN-79: fix sample data generation 2024-12-23 00:18:00 +01:00
Markus Thielker
53247d382d
N-FIN-79: remove unused auth.ts 2024-12-23 00:17:52 +01:00
Markus Thielker
57f3381829
N-FIN-79: migrate database dropping lucia tables 2024-12-23 00:17:43 +01:00
Markus Thielker
ba71cbef0e
N-FIN-79: use sign out url constant 2024-12-23 00:17:24 +01:00
Markus Thielker
12c689d1d6
N-FIN-79: refactor server actions to use auth0 session 2024-12-23 00:17:17 +01:00
Markus Thielker
c4146a36a4
N-FIN-79: remove lucia authentication components 2024-12-23 00:17:09 +01:00
Markus Thielker
6ba9a8872b
N-FIN-79: refactor pages to use auth0 session 2024-12-23 00:16:52 +01:00
Markus Thielker
642d64ad5e
N-FIN-79: add auth0 sdk 2024-12-23 00:16:42 +01:00
Markus Thielker
39cd91a53a
Release v1.2.0 (#65) 2024-03-24 20:43:43 +01:00
54 changed files with 1681 additions and 8418 deletions

View file

@ -5,4 +5,10 @@
# #
# prisma database url # prisma database url
DATABASE_URL="postgresql://prisma:prisma@localhost:5432/finances?schema=public" DATABASE_URL='postgresql://prisma:prisma@localhost:5432/finances?schema=public'
AUTH0_SECRET=''
AUTH0_BASE_URL='http://localhost:3000'
AUTH0_URL=''
AUTH0_CLIENT_ID=''
AUTH0_CLIENT_SECRET=''

View file

@ -0,0 +1,34 @@
name: Development Deployment
on:
workflow_dispatch:
inputs:
image_tag:
required: true
type: string
description: Docker image tag
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
push: true
platforms: linux/amd64
tags: markusthielker/next-finances:development, markusthielker/next-finances:${{ github.event.inputs.image_tag }}-dev

View file

@ -1,4 +1,4 @@
name: Docker Image Build and Push name: Production Deployment
on: on:
push: push:

View file

@ -1,4 +1,4 @@
FROM node:21-alpine AS base FROM oven/bun:1-alpine AS base
# Install dependencies only when needed # Install dependencies only when needed
FROM base AS deps FROM base AS deps
@ -6,8 +6,8 @@ FROM base AS deps
RUN apk add --no-cache libc6-compat RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json bun.lockb* ./
RUN npm ci RUN bun install
# Rebuild the source code only when needed # Rebuild the source code only when needed
@ -18,23 +18,23 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# dependencies have to be changed depending on target architecture # dependencies have to be changed depending on target architecture
RUN npm i @node-rs/argon2-linux-x64-musl # arm64 = @node-rs/argon2-linux-arm64-musl RUN bun install @node-rs/argon2-linux-x64-musl # arm64 = @node-rs/argon2-linux-arm64-musl
RUN npm i @node-rs/bcrypt-linux-x64-musl # arm64 = @node-rs/bcrypt-linux-arm64-musl RUN bun install @node-rs/bcrypt-linux-x64-musl # arm64 = @node-rs/bcrypt-linux-arm64-musl
COPY prisma/ ./prisma/ COPY prisma/ ./prisma/
RUN npx prisma generate RUN bunx prisma generate
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build RUN bun run build
# Production image, copy all the files and run next # Production image, copy all the files and run next
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED 1 ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs RUN adduser --system --uid 1001 nextjs
@ -52,7 +52,7 @@ USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT 3000 ENV PORT=3000
ENV HOSTNAME "0.0.0.0" ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"] CMD ["node", "server.js"]

View file

@ -7,20 +7,20 @@ This is my simple finances tracker that I use to keep track of my spending.
### Understanding the Basics ### Understanding the Basics
- **Entities**: The core building blocks of your finances. - **Entities**: The core building blocks of your finances.
- Accounts: Where you hold money (e.g., bank accounts, PayPal account, cash) - Accounts: Where you hold money (e.g., bank accounts, PayPal account, cash)
- Entities: Where you spend money (e.g., Walmart, Spotify, Netflix) - Entities: Where you spend money (e.g., Walmart, Spotify, Netflix)
- **Payments**: Record money movement. - **Payments**: Record money movement.
- Expenses: Money leaving an Account. (Account -> Entity) - Expenses: Money leaving an Account. (Account -> Entity)
- Income: Money entering an Account. (Entity -> Account) - Income: Money entering an Account. (Entity -> Account)
- **Categories** *(optional)*: Add labels to Payments for better tracking. - **Categories** *(optional)*: Add labels to Payments for better tracking.
### Your First Steps ### Your First Steps
- Set up: Create Entities and Accounts that reflect your finances. - Set up: Create Entities and Accounts that reflect your finances.
- Record a Payment: - Record a Payment:
- Enter the amount and date. - Enter the amount and date.
- Select payor and payee - Select payor and payee
- *(optional)* Assign a category or enter a note. - *(optional)* Assign a category or enter a note.
- Explore: View your payment history and view your statics at the dashboard - Explore: View your payment history and view your statics at the dashboard
### Tips ### Tips
@ -42,17 +42,22 @@ cp .env.example .env
docker compose -f docker/finances-dev/docker-compose.yml up -d docker compose -f docker/finances-dev/docker-compose.yml up -d
## generate prisma client ## generate prisma client
npx prisma generate bunx prisma generate
## apply database migrations ## apply database migrations
npx prisma migrate deploy bunx prisma migrate deploy
## start the development server ## start the development server
npm run dev bun run dev
``` ```
Then open [http://localhost:3000](http://localhost:3000) with your browser and create an account. This project relies on [Auth0](https://auth0.com) authentication. To use it you will have to create an Auth0 account,
create an application of type 'Single Page Application' and now add the required details to your .env file.
You will also have to add `http://localhost:3000/auth/callback` as an `Allowed Callback URL` and `http://localhost:3000`
as an `Allowed Logout URL` and `Allowed Web Origins` in your Auth0 console.
Now open [http://localhost:3000](http://localhost:3000) with your browser and create an account.
While in development mode, you can generate sample data from the [Account page](http://localhost:3000/account). While in development mode, you can generate sample data from the [Account page](http://localhost:3000/account).
## Deployment ## Deployment

1203
bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
command: npx prisma migrate deploy command: bunx prisma migrate deploy
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=true"
networks: networks:
@ -42,7 +42,7 @@ services:
depends_on: depends_on:
app-migrations: app-migrations:
condition: service_completed_successfully condition: service_completed_successfully
command: npx prisma studio command: bunx prisma studio
restart: unless-stopped restart: unless-stopped
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"

View file

@ -10,4 +10,4 @@ docker run -d \
--restart unless-stopped \ --restart unless-stopped \
-v $HOME/.docker/config.json:/config.json \ -v $HOME/.docker/config.json:/config.json \
-v /var/run/docker.sock:/var/run/docker.sock \ -v /var/run/docker.sock:/var/run/docker.sock \
containrrr/watchtower -s "*/30 * * * * *" --label-enable containrrr/watchtower -s "*/30 * * * * *" --label-enable --include-stopped

7671
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
"name": "next-finances", "name": "next-finances",
"description": "A finances application to keep track of my personal spendings", "description": "A finances application to keep track of my personal spendings",
"homepage": "https://github.com/MarkusThielker/next-finances", "homepage": "https://github.com/MarkusThielker/next-finances",
"version": "1.2.0", "version": "1.3.1",
"author": { "author": {
"name": "Markus Thielker" "name": "Markus Thielker"
}, },
@ -18,54 +18,53 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.3.4", "@auth0/nextjs-auth0": "^4.1.0",
"@lucia-auth/adapter-prisma": "^4.0.0", "@hookform/resolvers": "^3.9.1",
"@prisma/client": "^5.10.2", "@prisma/client": "^6.1.0",
"@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-navigation-menu": "^1.1.4", "@radix-ui/react-navigation-menu": "^1.2.3",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.1.2",
"@serwist/next": "^8.4.4", "@serwist/next": "^9.0.11",
"@serwist/precaching": "^8.4.4", "@serwist/precaching": "^9.0.11",
"@serwist/sw": "^8.4.4", "@serwist/sw": "^9.0.11",
"@tanstack/react-table": "^8.13.2", "@tanstack/react-table": "^8.20.6",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.0", "clsx": "^2.1.1",
"cmdk": "^1.0.0", "cmdk": "^1.0.4",
"date-fns": "^3.3.1", "date-fns": "^4.1.0",
"lucia": "^3.0.1", "lucia": "^3.2.2",
"lucide-react": "^0.350.0", "lucide-react": "^0.469.0",
"next": "14.1.3", "next": "15.1.2",
"next-themes": "^0.2.1", "next-themes": "^0.4.4",
"oslo": "^1.1.3", "react": "^19.0.0",
"react": "^18", "react-day-picker": "8.10.1",
"react-day-picker": "^8.10.0", "react-dom": "^19.0.0",
"react-dom": "^18", "react-hook-form": "^7.54.2",
"react-hook-form": "^7.51.0", "sonner": "^1.7.1",
"sonner": "^1.4.3", "swr": "^2.3.0",
"swr": "^2.2.5", "tailwind-merge": "^2.6.0",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.0", "vaul": "^1.1.2",
"zod": "^3.22.4" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.11.25", "@types/node": "^22.10.2",
"@types/react": "^18", "@types/react": "^19.0.2",
"@types/react-dom": "^18", "@types/react-dom": "^19.0.2",
"autoprefixer": "^10.0.1", "autoprefixer": "^10.4.20",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "14.1.3", "eslint-config-next": "15.1.2",
"postcss": "^8", "postcss": "^8",
"prisma": "^5.10.2", "prisma": "^6.1.0",
"tailwindcss": "^3.3.0", "tailwindcss": "^3.4.17",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.4.2" "typescript": "^5.7.2"
} }
} }

View file

@ -0,0 +1,28 @@
/*
Warnings:
- You are about to drop the `lucia_session` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `lucia_user` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "categories"
DROP CONSTRAINT "categories_user_id_fkey";
-- DropForeignKey
ALTER TABLE "entities"
DROP CONSTRAINT "entities_user_id_fkey";
-- DropForeignKey
ALTER TABLE "lucia_session"
DROP CONSTRAINT "lucia_session_userId_fkey";
-- DropForeignKey
ALTER TABLE "payments"
DROP CONSTRAINT "payments_user_id_fkey";
-- DropTable
DROP TABLE "lucia_session";
-- DropTable
DROP TABLE "lucia_user";

View file

@ -7,36 +7,9 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model User {
// lucia internal fields
id String @id
sessions Session[]
// custom fields
username String @unique
password String
entities Entity[]
payments Payment[]
categories Category[]
@@map("lucia_user")
}
model Session {
// lucia internal fields
id String @id
userId String
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
@@map("lucia_session")
}
model Entity { model Entity {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String @map("user_id") userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
name String name String
type EntityType type EntityType
defaultCategory Category? @relation(fields: [defaultCategoryId], references: [id]) defaultCategory Category? @relation(fields: [defaultCategoryId], references: [id])
@ -59,7 +32,6 @@ enum EntityType {
model Payment { model Payment {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String @map("user_id") userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
amount Int amount Int
currency String @default("EUR") currency String @default("EUR")
date DateTime @default(now()) date DateTime @default(now())
@ -79,7 +51,6 @@ model Payment {
model Category { model Category {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
userId String @map("user_id") userId String @map("user_id")
user User @relation(fields: [userId], references: [id])
name String name String
color String color String
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")

15
public/logo_t_hq_o.svg Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 2250 2250"
version="1.1" xmlns="http://www.w3.org/2000/svg" xml:space="preserve"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g id="frame"><rect id="tl" x="0" y="0" width="125" height="520.833" style="fill:#f50;"/><rect
id="t" x="0" y="-0" width="2250" height="125" style="fill:#f50;"/><rect id="tr" x="2125" y="0" width="125"
height="520.833" style="fill:#f50;"/><rect
id="bl" x="0" y="1729.17" width="125" height="520.833" style="fill:#f50;"/><rect id="b" x="0" y="2125"
width="2250" height="125"
style="fill:#f50;"/><rect
id="br" x="2125" y="1729.17" width="125" height="520.833" style="fill:#f50;"/></g>
<g id="text"><g id="text1" serif:id="text" transform="matrix(1,0,0,0.947314,7.10543e-15,-3.78368)"><g transform="matrix(1636.19,0,0,1636.19,2149.91,1766.12)"></g><text
x="-57.321px" y="1766.12px" style="font-family:'Akshar-Regular', 'Akshar';font-size:1636.19px;fill:#f50;">t<tspan
x="513.711px 891.672px 1598.51px " y="1766.12px 1766.12px 1766.12px ">lkr</tspan></text></g>
<circle id="dot" cx="2156.78" cy="1578.59" r="88.542" style="fill:#f50;"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

15
public/logo_t_hq_w.svg Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns:serif="http://www.serif.com/" width="100%" height="100%" viewBox="0 0 2250 2250"
version="1.1" xmlns="http://www.w3.org/2000/svg" xml:space="preserve"
style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g id="frame"><rect id="tl" x="0" y="0" width="125" height="520.833" style="fill:#fff;"/><rect
id="t" x="0" y="-0" width="2250" height="125" style="fill:#fff;"/><rect id="tr" x="2125" y="0" width="125"
height="520.833" style="fill:#fff;"/><rect
id="bl" x="0" y="1729.17" width="125" height="520.833" style="fill:#fff;"/><rect id="b" x="0" y="2125"
width="2250" height="125"
style="fill:#fff;"/><rect
id="br" x="2125" y="1729.17" width="125" height="520.833" style="fill:#fff;"/></g>
<g id="text"><g id="text1" serif:id="text" transform="matrix(1,0,0,0.947314,7.10543e-15,-3.78368)"><g transform="matrix(1636.19,0,0,1636.19,2149.91,1766.12)"></g><text
x="-57.321px" y="1766.12px" style="font-family:'Akshar-Regular', 'Akshar';font-size:1636.19px;fill:#fff;">t<tspan
x="513.711px 891.672px 1598.51px " y="1766.12px 1766.12px 1766.12px ">lkr</tspan></text></g>
<circle id="dot" cx="2156.78" cy="1578.59" r="88.542" style="fill:#fff;"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

View file

@ -3,7 +3,7 @@
"short_name": "Finances", "short_name": "Finances",
"icons": [ "icons": [
{ {
"src": "/logo_white.png", "src": "/logo_t_hq_o.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
} }

View file

@ -1,42 +1,42 @@
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import React from 'react'; import React from 'react';
import { getUser } from '@/auth';
import { redirect } from 'next/navigation';
import signOut from '@/lib/actions/signOut';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { URL_SIGN_IN } from '@/lib/constants';
import generateSampleData from '@/lib/actions/generateSampleData'; import generateSampleData from '@/lib/actions/generateSampleData';
import prisma from '@/prisma'; import prisma from '@/prisma';
import { ServerActionTrigger } from '@/components/form/serverActionTrigger'; import { ServerActionTrigger } from '@/components/form/serverActionTrigger';
import accountDelete from '@/lib/actions/accountDelete'; import clearAccountData from '@/lib/actions/clearAccountData';
import { Button } from '@/components/ui/button';
import { URL_SIGN_OUT } from '@/lib/constants';
import { auth0 } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function AccountPage() { export default async function AccountPage() {
const user = await getUser(); const session = await auth0.getSession();
if (!session) {
if (!user) { return redirect('/auth/login');
redirect(URL_SIGN_IN);
} }
const user = session.user;
let paymentCount = 0; let paymentCount = 0;
paymentCount = await prisma.payment.count({ paymentCount = await prisma.payment.count({
where: { where: {
userId: user.id, userId: user.sub,
}, },
}); });
let entityCount = 0; let entityCount = 0;
entityCount = await prisma.entity.count({ entityCount = await prisma.entity.count({
where: { where: {
userId: user.id, userId: user.sub,
}, },
}); });
let categoryCount = 0; let categoryCount = 0;
categoryCount = await prisma.category.count({ categoryCount = await prisma.category.count({
where: { where: {
userId: user.id, userId: user.sub,
}, },
}); });
@ -44,7 +44,7 @@ export default async function AccountPage() {
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<Card className="w-full max-w-md md:mt-12"> <Card className="w-full max-w-md md:mt-12">
<CardHeader> <CardHeader>
<CardTitle>Hey, {user?.username}!</CardTitle> <CardTitle>Hey, {user.name}!</CardTitle>
<CardDescription>This is your account overview.</CardDescription> <CardDescription>This is your account overview.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
@ -52,13 +52,13 @@ export default async function AccountPage() {
<Label>ID</Label> <Label>ID</Label>
<Input <Input
disabled disabled
value={user?.id}/> value={user.sub}/>
</div> </div>
<div> <div>
<Label>Username</Label> <Label>Username</Label>
<Input <Input
disabled disabled
value={user?.username}/> value={user.name}/>
</div> </div>
<div className="flex flex-row items-center space-x-4"> <div className="flex flex-row items-center space-x-4">
<div> <div>
@ -83,19 +83,21 @@ export default async function AccountPage() {
</CardContent> </CardContent>
<CardFooter className="w-full grid gap-4 grid-cols-1 md:grid-cols-2"> <CardFooter className="w-full grid gap-4 grid-cols-1 md:grid-cols-2">
<ServerActionTrigger <ServerActionTrigger
action={accountDelete} action={clearAccountData}
dialog={{ dialog={{
title: 'Delete Account', title: 'Clear account data',
description: 'Are you sure you want to delete your account? This action is irreversible.', description: 'Are you sure you want to delete all payments, entities and categories from you account? This action is irreversible.',
actionText: 'Delete Account', actionText: 'Clear data',
actionVariant: 'destructive',
}} }}
variant="outline"> variant="outline">
Delete Account Clear data
</ServerActionTrigger>
<ServerActionTrigger
action={signOut}>
Sign Out
</ServerActionTrigger> </ServerActionTrigger>
<a href={URL_SIGN_OUT}>
<Button className="w-full">
Sign Out
</Button>
</a>
{ {
process.env.NODE_ENV === 'development' && ( process.env.NODE_ENV === 'development' && (
<ServerActionTrigger <ServerActionTrigger

View file

@ -1,13 +0,0 @@
import React from 'react';
export default function AuthLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="flex justify-center">
{children}
</div>
);
}

View file

@ -1,25 +0,0 @@
import React from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import SignInForm from '@/components/form/signInForm';
import signIn from '@/lib/actions/signIn';
import Link from 'next/link';
import { URL_SIGN_UP } from '@/lib/constants';
export default async function SignInPage() {
return (
<Card className="w-full max-w-md mt-12">
<CardHeader>
<CardTitle>Sign in</CardTitle>
<CardDescription>Sign into your existing account</CardDescription>
</CardHeader>
<CardContent>
<SignInForm onSubmit={signIn}/>
</CardContent>
<CardFooter>
<Link href={URL_SIGN_UP}>
Don&apos;t have an account? Sign up
</Link>
</CardFooter>
</Card>
);
}

View file

@ -1,25 +0,0 @@
import React from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import signUp from '@/lib/actions/signUp';
import SignUpForm from '@/components/form/signUpForm';
import Link from 'next/link';
import { URL_SIGN_IN } from '@/lib/constants';
export default async function SignUpPage() {
return (
<Card className="w-full max-w-md mt-12">
<CardHeader>
<CardTitle>Sign up</CardTitle>
<CardDescription>Create a new account.</CardDescription>
</CardHeader>
<CardContent>
<SignUpForm onSubmit={signUp}/>
</CardContent>
<CardFooter>
<Link href={URL_SIGN_IN}>
Already have an account? Sign in
</Link>
</CardFooter>
</Card>
);
}

View file

@ -1,13 +1,18 @@
import { getUser } from '@/auth';
import prisma from '@/prisma'; import prisma from '@/prisma';
import React from 'react'; import React from 'react';
import CategoryPageClientContent from '@/components/categoryPageClientComponents'; import CategoryPageClientContent from '@/components/categoryPageClientComponents';
import categoryCreateUpdate from '@/lib/actions/categoryCreateUpdate'; import categoryCreateUpdate from '@/lib/actions/categoryCreateUpdate';
import categoryDelete from '@/lib/actions/categoryDelete'; import categoryDelete from '@/lib/actions/categoryDelete';
import { auth0 } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function CategoriesPage() { export default async function CategoriesPage() {
const user = await getUser(); const session = await auth0.getSession();
if (!session) {
return redirect('/auth/login');
}
const user = session.user;
const categories = await prisma.category.findMany({ const categories = await prisma.category.findMany({
where: { where: {

View file

@ -1,13 +1,18 @@
import prisma from '@/prisma'; import prisma from '@/prisma';
import { getUser } from '@/auth';
import React from 'react'; import React from 'react';
import EntityPageClientContent from '@/components/entityPageClientComponents'; import EntityPageClientContent from '@/components/entityPageClientComponents';
import entityCreateUpdate from '@/lib/actions/entityCreateUpdate'; import entityCreateUpdate from '@/lib/actions/entityCreateUpdate';
import entityDelete from '@/lib/actions/entityDelete'; import entityDelete from '@/lib/actions/entityDelete';
import { auth0 } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function EntitiesPage() { export default async function EntitiesPage() {
const user = await getUser(); const session = await auth0.getSession();
if (!session) {
return redirect('/auth/login');
}
const user = session.user;
const entities = await prisma.entity.findMany({ const entities = await prisma.entity.findMany({
where: { where: {

View file

@ -46,7 +46,7 @@ export default function RootLayout({
<link crossOrigin="use-credentials" rel="manifest" href="/manifest.json"/> <link crossOrigin="use-credentials" rel="manifest" href="/manifest.json"/>
<link <link
rel="icon" rel="icon"
href="/logo_white.png" href="/logo_t_hq_o.svg"
/> />
</head> </head>
<body className={cn('dark', inter.className)}> <body className={cn('dark', inter.className)}>

View file

@ -2,8 +2,9 @@ import React from 'react';
import { Category, Entity, EntityType } from '@prisma/client'; import { Category, Entity, EntityType } from '@prisma/client';
import { Scope, ScopeType } from '@/lib/types/scope'; import { Scope, ScopeType } from '@/lib/types/scope';
import prisma from '@/prisma'; import prisma from '@/prisma';
import { getUser } from '@/auth';
import DashboardPageClient from '@/components/dashboardPageClientComponents'; import DashboardPageClient from '@/components/dashboardPageClientComponents';
import { auth0 } from '@/lib/auth';
import { redirect } from 'next/navigation';
export type CategoryNumber = { export type CategoryNumber = {
category: Category, category: Category,
@ -15,19 +16,20 @@ export type EntityNumber = {
value: number, value: number,
} }
export default async function DashboardPage(props: { searchParams?: { scope: ScopeType } }) { export default async function DashboardPage(props: { searchParams?: Promise<{ scope: ScopeType }> }) {
const user = await getUser(); const session = await auth0.getSession();
if (!user) { if (!session) {
return; return redirect('/auth/login');
} }
const user = session.user;
const scope = Scope.of(props.searchParams?.scope || ScopeType.ThisMonth); const scope = Scope.of((await props.searchParams)?.scope || ScopeType.ThisMonth);
// get all payments in the current scope // get all payments in the current scope
const payments = await prisma.payment.findMany({ const payments = await prisma.payment.findMany({
where: { where: {
userId: user?.id, userId: user.sub,
date: { date: {
gte: scope.start, gte: scope.start,
lte: scope.end, lte: scope.end,

View file

@ -1,17 +1,22 @@
import { getUser } from '@/auth';
import prisma from '@/prisma'; import prisma from '@/prisma';
import React from 'react'; import React from 'react';
import PaymentPageClientContent from '@/components/paymentPageClientComponents'; import PaymentPageClientContent from '@/components/paymentPageClientComponents';
import paymentCreateUpdate from '@/lib/actions/paymentCreateUpdate'; import paymentCreateUpdate from '@/lib/actions/paymentCreateUpdate';
import paymentDelete from '@/lib/actions/paymentDelete'; import paymentDelete from '@/lib/actions/paymentDelete';
import { auth0 } from '@/lib/auth';
import { redirect } from 'next/navigation';
export default async function PaymentsPage() { export default async function PaymentsPage() {
const user = await getUser(); const session = await auth0.getSession();
if (!session) {
return redirect('/auth/login');
}
const user = session.user;
const payments = await prisma.payment.findMany({ const payments = await prisma.payment.findMany({
where: { where: {
userId: user?.id, userId: user.sub,
}, },
orderBy: [ orderBy: [
{ {
@ -25,7 +30,7 @@ export default async function PaymentsPage() {
const entities = await prisma.entity.findMany({ const entities = await prisma.entity.findMany({
where: { where: {
userId: user?.id, userId: user.sub,
}, },
orderBy: [ orderBy: [
{ {
@ -39,7 +44,7 @@ export default async function PaymentsPage() {
const categories = await prisma.category.findMany({ const categories = await prisma.category.findMany({
where: { where: {
userId: user?.id, userId: user.sub,
}, },
orderBy: [ orderBy: [
{ {

View file

@ -1,15 +1,46 @@
import { defaultCache } from '@serwist/next/browser';
import type { PrecacheEntry } from '@serwist/precaching'; import type { PrecacheEntry } from '@serwist/precaching';
import { installSerwist } from '@serwist/sw'; import { defaultCache } from '@serwist/next/worker';
import { Serwist, SerwistGlobalConfig } from 'serwist';
declare const self: ServiceWorkerGlobalScope & { declare const self: ServiceWorkerGlobalScope & {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined; __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}; };
installSerwist({ declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
// Change this attribute's name to your \`injectionPoint\`.
// \`injectionPoint\` is an InjectManifest option.
// See https://serwist.pages.dev/docs/build/configuring
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}
}
const serwist = new Serwist({
// A list of URLs that should be cached. Usually, you don't generate
// this list yourself; rather, you'd rely on a Serwist build tool/your framework
// to do it for you. In this example, it is generated by \`@serwist/vite\`.
precacheEntries: self.__SW_MANIFEST, precacheEntries: self.__SW_MANIFEST,
// Options to customize how Serwist precaches the URLs.
precacheOptions: {
// Whether outdated caches should be removed.
cleanupOutdatedCaches: true,
concurrency: 10,
ignoreURLParametersMatching: [],
},
// Whether the service worker should skip waiting and become the active one.
skipWaiting: true, skipWaiting: true,
// Whether the service worker should claim any currently available clients.
clientsClaim: true, clientsClaim: true,
// Whether navigation preloading should be used.
navigationPreload: true, navigationPreload: true,
// Whether Serwist should log in development mode.
disableDevLogs: true,
// A list of runtime caching entries. When a request is made and its URL match
// any of the entries, the response to it will be cached according to the matching
// entry's \`handler\`. This does not apply to precached URLs.
runtimeCaching: defaultCache, runtimeCaching: defaultCache,
// Other options...
// See https://serwist.pages.dev/docs/serwist/core/serwist
}); });
serwist.addEventListeners();

View file

@ -1,67 +0,0 @@
import { Lucia } from 'lucia';
import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
import { cookies } from 'next/headers';
import prisma from '@/prisma';
const adapter = new PrismaAdapter(prisma.session, prisma.user);
export const lucia = new Lucia(adapter, {
sessionCookie: {
expires: false,
attributes: {
sameSite: 'strict',
domain: process.env.NODE_ENV === 'production' ? process.env.COOKIE_DOMAIN : undefined,
secure: process.env.NODE_ENV === 'production',
},
},
getUserAttributes: (attributes) => {
return {
username: attributes.username,
};
},
});
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseUserAttributes {
username: string;
}
export function getSessionId() {
return cookies().get(lucia.sessionCookieName)?.value ?? null;
}
export async function getSession() {
const sessionId = getSessionId();
if (!sessionId) {
return null;
}
const {session} = await lucia.validateSession(sessionId);
return session;
}
export async function getUser() {
const sessionId = getSessionId();
if (!sessionId) {
return null;
}
const {user, session} = await lucia.validateSession(sessionId);
try {
if (session && session.fresh) {
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
if (!session) {
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
}
} catch {
// Next.js throws error when attempting to set cookies when rendering page
}
return user;
}

View file

@ -67,9 +67,9 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
}; };
}) ?? []; }) ?? [];
const payeeRef = useRef<HTMLInputElement>(null); const payeeRef = useRef<HTMLInputElement>({} as HTMLInputElement);
const categoryRef = useRef<HTMLInputElement>(null); const categoryRef = useRef<HTMLInputElement>({} as HTMLInputElement);
const noteRef = useRef<HTMLInputElement>(null); const submitRef = useRef<HTMLButtonElement>({} as HTMLButtonElement);
return ( return (
<Form {...form}> <Form {...form}>
@ -125,10 +125,7 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
<Calendar <Calendar
mode="single" mode="single"
selected={field.value} selected={field.value}
onSelect={(e) => { onSelect={field.onChange}
field.onChange(e);
}}
initialFocus
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@ -147,8 +144,13 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
<AutoCompleteInput <AutoCompleteInput
placeholder="Select payor" placeholder="Select payor"
items={entitiesMapped} items={entitiesMapped}
next={payeeRef} {...field}
{...field} /> onChange={(e) => {
field.onChange(e);
if (e && e.target.value) {
payeeRef && payeeRef.current.focus();
}
}}/>
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
</FormItem> </FormItem>
@ -165,15 +167,18 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
<AutoCompleteInput <AutoCompleteInput
placeholder="Select payee" placeholder="Select payee"
items={entitiesMapped} items={entitiesMapped}
next={categoryRef}
{...field} {...field}
onChange={(e) => { onChange={(e) => {
field.onChange(e); field.onChange(e);
if (e && e.target.value) { if (e && e.target.value) {
const entity = entities.find((entity) => entity.id === Number(e.target.value)); const entity = entities.find((entity) => entity.id === Number(e.target.value));
console.log(entity?.defaultCategoryId);
// only focus category input if payee has no default category
if (entity?.defaultCategoryId !== null) { if (entity?.defaultCategoryId !== null) {
form.setValue('categoryId', entity?.defaultCategoryId); form.setValue('categoryId', entity?.defaultCategoryId);
submitRef && submitRef.current.focus();
} else {
categoryRef && categoryRef.current.focus();
} }
} }
}}/> }}/>
@ -193,8 +198,14 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
<AutoCompleteInput <AutoCompleteInput
placeholder="Select category" placeholder="Select category"
items={categoriesMapped} items={categoriesMapped}
next={noteRef} {...field}
{...field} /> onChange={(e) => {
field.onChange(e);
if (e && e.target.value) {
submitRef && submitRef.current.focus();
}
}}
/>
</FormControl> </FormControl>
<FormMessage/> <FormMessage/>
</FormItem> </FormItem>
@ -216,7 +227,8 @@ export default function PaymentForm({value, entities, categories, onSubmit, clas
)} )}
/> />
<Button type="submit" className="w-full">{value?.id ? 'Update Payment' : 'Create Payment'}</Button> <Button type="submit" ref={submitRef}
className="w-full">{value?.id ? 'Update Payment' : 'Create Payment'}</Button>
</form> </form>
</Form> </Form>
); );

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { buttonVariants } from '@/components/ui/button'; import { Button, buttonVariants } from '@/components/ui/button';
import React from 'react'; import React from 'react';
import { Slot } from '@radix-ui/react-slot'; import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@ -25,6 +25,7 @@ export interface ConfirmationDialogProps {
title: string; title: string;
description?: string; description?: string;
actionText?: string; actionText?: string;
actionVariant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
} }
export interface ButtonWithActionProps<T = any> export interface ButtonWithActionProps<T = any>
@ -76,9 +77,11 @@ const ServerActionTrigger = React.forwardRef<HTMLButtonElement, ButtonWithAction
<AlertDialogCancel> <AlertDialogCancel>
Cancel Cancel
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction onClick={handleSubmit}> <Button variant={props.dialog.actionVariant || 'default'} asChild>
{props.dialog.actionText || 'Confirm'} <AlertDialogAction onClick={handleSubmit}>
</AlertDialogAction> {props.dialog.actionText || 'Confirm'}
</AlertDialogAction>
</Button>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View file

@ -1,71 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import React from 'react';
import { Button } from '@/components/ui/button';
import { signInFormSchema } from '@/lib/form-schemas/signInFormSchema';
import { ActionResponse } from '@/lib/types/actionResponse';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { sonnerContent } from '@/components/ui/sonner';
export default function SignInForm({onSubmit}: {
onSubmit: (data: z.infer<typeof signInFormSchema>) => Promise<ActionResponse>
}) {
const router = useRouter();
const form = useForm<z.infer<typeof signInFormSchema>>({
resolver: zodResolver(signInFormSchema),
defaultValues: {
username: '',
password: '',
},
});
const handleSubmit = async (data: z.infer<typeof signInFormSchema>) => {
const response = await onSubmit(data);
toast(sonnerContent(response));
if (response.redirect) {
router.push(response.redirect);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-2">
<FormField
control={form.control}
name="username"
render={({field}) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Username" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="••••••••" type="password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button type="submit" className="w-full">Sign in</Button>
</form>
</Form>
);
}

View file

@ -1,84 +0,0 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import React from 'react';
import { Button } from '@/components/ui/button';
import { signUpFormSchema } from '@/lib/form-schemas/signUpFormSchema';
import { ActionResponse } from '@/lib/types/actionResponse';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { sonnerContent } from '@/components/ui/sonner';
export default function SignUpForm({onSubmit}: {
onSubmit: (data: z.infer<typeof signUpFormSchema>) => Promise<ActionResponse>
}) {
const router = useRouter();
const form = useForm<z.infer<typeof signUpFormSchema>>({
resolver: zodResolver(signUpFormSchema),
defaultValues: {
username: '',
password: '',
},
});
const handleSubmit = async (data: z.infer<typeof signUpFormSchema>) => {
const response = await onSubmit(data);
toast(sonnerContent(response));
if (response.redirect) {
router.push(response.redirect);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-2">
<FormField
control={form.control}
name="username"
render={({field}) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="Username" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="••••••••" type="password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm"
render={({field}) => (
<FormItem>
<FormLabel>Confirm password</FormLabel>
<FormControl>
<Input placeholder="••••••••" type="password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
<Button type="submit" className="w-full">Create Account</Button>
</form>
</Form>
);
}

View file

@ -78,7 +78,7 @@ export default function Navigation() {
<NavigationMenuList className="flex w-screen items-center justify-between sm:px-4 py-2"> <NavigationMenuList className="flex w-screen items-center justify-between sm:px-4 py-2">
<div className="inline-flex space-x-2"> <div className="inline-flex space-x-2">
<img src={'/logo_white.png'} alt="Finances" className="h-10 w-10 mx-3"/> <img src={'/logo_t_hq_w.svg'} alt="Finances" className="h-10 w-10 mx-3"/>
<NavigationMenuItem> <NavigationMenuItem>
<Link href="/" legacyBehavior passHref> <Link href="/" legacyBehavior passHref>

View file

@ -9,7 +9,6 @@ import { Button } from '@/components/ui/button';
export interface AutoCompleteInputProps export interface AutoCompleteInputProps
extends React.InputHTMLAttributes<HTMLInputElement> { extends React.InputHTMLAttributes<HTMLInputElement> {
items: { label: string, value: any }[]; items: { label: string, value: any }[];
next?: React.RefObject<HTMLInputElement>;
} }
const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoCompleteInputProps>( const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoCompleteInputProps>(
@ -32,7 +31,6 @@ const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoCompleteInputPr
function handleChange(e: React.ChangeEvent<HTMLInputElement>) { function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
props.onChange?.(undefined as any);
const value = e.target.value; const value = e.target.value;
setFilteredItems(props?.items?.filter((item) => { setFilteredItems(props?.items?.filter((item) => {
@ -41,19 +39,26 @@ const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoCompleteInputPr
setValue(value); setValue(value);
setOpen(value.length > 0); setOpen(value.length > 0);
// on typing only the internal state is changed while the form state is
// set to undefined. This way only the predefined items are actual values
// for the form validation
props.onChange?.(undefined as any);
} }
// since typing changes the internal values and therefor the selected value, this effect
// handles every filteredItems change to check if only one item is left
useEffect(() => { useEffect(() => {
// only one item is left and the last character was a letter or digit.
// the last condition has to be checked to make it possible to use the backspace
if (filteredItems.length === 1 && /^[a-zA-Z0-9]$/.test(lastKey)) { if (filteredItems.length === 1 && /^[a-zA-Z0-9]$/.test(lastKey)) {
setValue(filteredItems[0].label); setValue(filteredItems[0].label);
setOpen(false); setOpen(false);
props.onChange?.({target: {value: filteredItems[0].value}} as any); props.onChange?.({target: {value: filteredItems[0].value}} as any);
props.next && props.next.current?.focus();
} }
}, [filteredItems]); }, [filteredItems]);
useEffect(() => { useEffect(() => {
console.log('Prop value changed', value, props.value);
if (props.value) { if (props.value) {
setValue(getNameOfPropValue()); setValue(getNameOfPropValue());
} else { } else {
@ -105,7 +110,6 @@ const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoCompleteInputPr
className="px-3 py-3 hover:bg-accent hover:text-accent-foreground cursor-pointer text-sm font-medium" className="px-3 py-3 hover:bg-accent hover:text-accent-foreground cursor-pointer text-sm font-medium"
onClick={() => { onClick={() => {
props.onChange?.({target: {value: item.value}} as any); props.onChange?.({target: {value: item.value}} as any);
props.next && props.next.current?.focus();
setValue(item.label); setValue(item.label);
setOpen(false); setOpen(false);
}} }}

View file

@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{ {
variants: { variants: {
variant: { variant: {
@ -40,11 +40,11 @@ export interface ButtonProps
} }
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({className, variant, size, asChild = false, ...props}, ref) => { ({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'; const Comp = asChild ? Slot : 'button';
return ( return (
<Comp <Comp
className={cn(buttonVariants({variant, size, className}))} className={cn(buttonVariants({ variant, size, className }))}
ref={ref} ref={ref}
{...props} {...props}
/> />

View file

@ -10,11 +10,11 @@ import { buttonVariants } from '@/components/ui/button';
export type CalendarProps = React.ComponentProps<typeof DayPicker> export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({ function Calendar({
className, className,
classNames, classNames,
showOutsideDays = true, showOutsideDays = true,
...props ...props
}: CalendarProps) { }: CalendarProps) {
return ( return (
<DayPicker <DayPicker
showOutsideDays={showOutsideDays} showOutsideDays={showOutsideDays}
@ -26,7 +26,7 @@ function Calendar({
caption_label: 'text-sm font-medium', caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center', nav: 'space-x-1 flex items-center',
nav_button: cn( nav_button: cn(
buttonVariants({variant: 'outline'}), buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100', 'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
), ),
nav_button_previous: 'absolute left-1', nav_button_previous: 'absolute left-1',
@ -38,7 +38,7 @@ function Calendar({
row: 'flex w-full mt-2', row: 'flex w-full mt-2',
cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20', cell: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
day: cn( day: cn(
buttonVariants({variant: 'ghost'}), buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal aria-selected:opacity-100', 'h-9 w-9 p-0 font-normal aria-selected:opacity-100',
), ),
day_range_end: 'day-range-end', day_range_end: 'day-range-end',
@ -46,7 +46,7 @@ function Calendar({
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground', 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground', day_today: 'bg-accent text-accent-foreground',
day_outside: day_outside:
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30', 'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
day_disabled: 'text-muted-foreground opacity-50', day_disabled: 'text-muted-foreground opacity-50',
day_range_middle: day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground', 'aria-selected:bg-accent aria-selected:text-accent-foreground',
@ -54,8 +54,12 @@ function Calendar({
...classNames, ...classNames,
}} }}
components={{ components={{
IconLeft: ({...props}) => <ChevronLeft className="h-4 w-4"/>, IconLeft: ({ className, ...props }) => (
IconRight: ({...props}) => <ChevronRight className="h-4 w-4"/>, <ChevronLeft className={cn('h-4 w-4', className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn('h-4 w-4', className)} {...props} />
),
}} }}
{...props} {...props}
/> />

View file

@ -12,7 +12,7 @@ const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef< const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>, React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({className, align = 'center', sideOffset = 4, ...props}, ref) => ( >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal> <PopoverPrimitive.Portal>
<PopoverPrimitive.Content <PopoverPrimitive.Content
ref={ref} ref={ref}

View file

@ -1,58 +0,0 @@
import { ActionResponse } from '@/lib/types/actionResponse';
import { URL_SIGN_IN } from '@/lib/constants';
import { getUser, lucia } from '@/auth';
import prisma from '@/prisma';
import { cookies } from 'next/headers';
export default async function accountDelete(): Promise<ActionResponse> {
'use server';
const user = await getUser();
if (!user) {
return {
type: 'error',
message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN,
};
}
await prisma.payment.deleteMany({
where: {
userId: user.id,
},
});
await prisma.entity.deleteMany({
where: {
userId: user.id,
},
});
await prisma.category.deleteMany({
where: {
userId: user.id,
},
});
await prisma.session.deleteMany({
where: {
userId: user.id,
},
});
await prisma.user.delete({
where: {
id: user.id,
},
});
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return {
type: 'success',
message: 'Your account was removed.',
redirect: URL_SIGN_IN,
};
}

View file

@ -1,9 +1,9 @@
import { z } from 'zod'; import { z } from 'zod';
import { ActionResponse } from '@/lib/types/actionResponse'; import { ActionResponse } from '@/lib/types/actionResponse';
import prisma from '@/prisma'; import prisma from '@/prisma';
import { getUser } from '@/auth';
import { URL_SIGN_IN } from '@/lib/constants'; import { URL_SIGN_IN } from '@/lib/constants';
import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema'; import { categoryFormSchema } from '@/lib/form-schemas/categoryFormSchema';
import { auth0 } from '@/lib/auth';
export default async function categoryCreateUpdate({ export default async function categoryCreateUpdate({
id, id,
@ -12,15 +12,15 @@ export default async function categoryCreateUpdate({
}: z.infer<typeof categoryFormSchema>): Promise<ActionResponse> { }: z.infer<typeof categoryFormSchema>): Promise<ActionResponse> {
'use server'; 'use server';
// check that user is logged in const session = await auth0.getSession();
const user = await getUser(); if (!session) {
if (!user) {
return { return {
type: 'error', type: 'error',
message: 'You must be logged in to create/update an category.', message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN, redirect: URL_SIGN_IN,
}; };
} }
const user = session.user;
// create/update category // create/update category
try { try {
@ -44,7 +44,7 @@ export default async function categoryCreateUpdate({
} else { } else {
await prisma.category.create({ await prisma.category.create({
data: { data: {
userId: user.id, userId: user.sub,
name: name, name: name,
color: color, color: color,
}, },

View file

@ -1,7 +1,7 @@
import { ActionResponse } from '@/lib/types/actionResponse'; import { ActionResponse } from '@/lib/types/actionResponse';
import prisma from '@/prisma'; import prisma from '@/prisma';
import { getUser } from '@/auth';
import { URL_SIGN_IN } from '@/lib/constants'; import { URL_SIGN_IN } from '@/lib/constants';
import { auth0 } from '@/lib/auth';
export default async function categoryDelete(id: number): Promise<ActionResponse> { export default async function categoryDelete(id: number): Promise<ActionResponse> {
'use server'; 'use server';
@ -14,21 +14,21 @@ export default async function categoryDelete(id: number): Promise<ActionResponse
}; };
} }
// check that user is logged in const session = await auth0.getSession();
const user = await getUser(); if (!session) {
if (!user) {
return { return {
type: 'error', type: 'error',
message: 'You must be logged in to delete an category.', message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN, redirect: URL_SIGN_IN,
}; };
} }
const user = session.user;
// check that category is associated with user // check that category is associated with user
const category = await prisma.category.findFirst({ const category = await prisma.category.findFirst({
where: { where: {
id: id, id: id,
userId: user.id, userId: user.sub,
}, },
}); });
if (!category) { if (!category) {
@ -38,15 +38,25 @@ export default async function categoryDelete(id: number): Promise<ActionResponse
}; };
} }
// delete category
try { try {
await prisma.category.delete({
await prisma.$transaction(async (tx) => {
// update related payments
await tx.payment.updateMany({
where: {categoryId: category.id},
data: {categoryId: null},
});
// delete the category
await tx.category.delete({
where: { where: {
id: category.id, id: category.id,
userId: user.id, userId: user.sub,
}, },
}, });
); });
} catch (e) { } catch (e) {
return { return {
type: 'error', type: 'error',

View file

@ -0,0 +1,40 @@
import { ActionResponse } from '@/lib/types/actionResponse';
import { URL_SIGN_IN } from '@/lib/constants';
import prisma from '@/prisma';
import { auth0 } from '@/lib/auth';
export default async function clearAccountData(): Promise<ActionResponse> {
'use server';
const session = await auth0.getSession();
if (!session) {
return {
type: 'error',
message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN,
};
}
await prisma.payment.deleteMany({
where: {
userId: session.user.sub,
},
});
await prisma.entity.deleteMany({
where: {
userId: session.user.sub,
},
});
await prisma.category.deleteMany({
where: {
userId: session.user.sub,
},
});
return {
type: 'success',
message: 'Your account data was cleared.',
};
}

View file

@ -2,8 +2,8 @@ import { z } from 'zod';
import { ActionResponse } from '@/lib/types/actionResponse'; import { ActionResponse } from '@/lib/types/actionResponse';
import { entityFormSchema } from '@/lib/form-schemas/entityFormSchema'; import { entityFormSchema } from '@/lib/form-schemas/entityFormSchema';
import prisma from '@/prisma'; import prisma from '@/prisma';
import { getUser } from '@/auth';
import { URL_SIGN_IN } from '@/lib/constants'; import { URL_SIGN_IN } from '@/lib/constants';
import { auth0 } from '@/lib/auth';
export default async function entityCreateUpdate({ export default async function entityCreateUpdate({
id, id,
@ -13,15 +13,15 @@ export default async function entityCreateUpdate({
}: z.infer<typeof entityFormSchema>): Promise<ActionResponse> { }: z.infer<typeof entityFormSchema>): Promise<ActionResponse> {
'use server'; 'use server';
// check that user is logged in const session = await auth0.getSession();
const user = await getUser(); if (!session) {
if (!user) {
return { return {
type: 'error', type: 'error',
message: 'You must be logged in to create/update an entity.', message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN, redirect: URL_SIGN_IN,
}; };
} }
const user = session.user;
// create/update entity // create/update entity
try { try {
@ -46,7 +46,7 @@ export default async function entityCreateUpdate({
} else { } else {
await prisma.entity.create({ await prisma.entity.create({
data: { data: {
userId: user.id, userId: user.sub,
name: name, name: name,
type: type, type: type,
defaultCategoryId: defaultCategoryId ?? null, defaultCategoryId: defaultCategoryId ?? null,

View file

@ -1,7 +1,7 @@
import { ActionResponse } from '@/lib/types/actionResponse'; import { ActionResponse } from '@/lib/types/actionResponse';
import prisma from '@/prisma'; import prisma from '@/prisma';
import { getUser } from '@/auth';
import { URL_SIGN_IN } from '@/lib/constants'; import { URL_SIGN_IN } from '@/lib/constants';
import { auth0 } from '@/lib/auth';
export default async function entityDelete(id: number): Promise<ActionResponse> { export default async function entityDelete(id: number): Promise<ActionResponse> {
'use server'; 'use server';
@ -14,21 +14,21 @@ export default async function entityDelete(id: number): Promise<ActionResponse>
}; };
} }
// check that user is logged in const session = await auth0.getSession();
const user = await getUser(); if (!session) {
if (!user) {
return { return {
type: 'error', type: 'error',
message: 'You must be logged in to delete an entity.', message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN, redirect: URL_SIGN_IN,
}; };
} }
const user = session.user;
// check that entity is associated with user // check that entity is associated with user
const entity = await prisma.entity.findFirst({ const entity = await prisma.entity.findFirst({
where: { where: {
id: id, id: id,
userId: user.id, userId: user.sub,
}, },
}); });
if (!entity) { if (!entity) {
@ -43,7 +43,7 @@ export default async function entityDelete(id: number): Promise<ActionResponse>
await prisma.entity.delete({ await prisma.entity.delete({
where: { where: {
id: entity.id, id: entity.id,
userId: user.id, userId: user.sub,
}, },
}, },
); );

View file

@ -1,32 +1,32 @@
import prisma from '@/prisma'; import prisma from '@/prisma';
import type { Category, Entity } from '@prisma/client'; import type { Category, Entity } from '@prisma/client';
import { EntityType } from '@prisma/client'; import { EntityType } from '@prisma/client';
import { getUser } from '@/auth';
import { URL_SIGN_IN } from '@/lib/constants'; import { URL_SIGN_IN } from '@/lib/constants';
import { ActionResponse } from '@/lib/types/actionResponse'; import { ActionResponse } from '@/lib/types/actionResponse';
import { auth0 } from '@/lib/auth';
export default async function generateSampleData(): Promise<ActionResponse> { export default async function generateSampleData(): Promise<ActionResponse> {
'use server'; 'use server';
const user = await getUser(); const session = await auth0.getSession();
if (!session) {
if (!user) {
return { return {
type: 'error', type: 'error',
message: 'You must be logged in to create/update an category.', message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN, redirect: URL_SIGN_IN,
}; };
} }
const user = session.user;
// Categories: create sample data // Categories: create sample data
const categories: Category[] = await prisma.category.findMany({where: {userId: user.id}}); const categories: Category[] = await prisma.category.findMany({where: {userId: user.sub}});
if (await prisma.category.count({where: {userId: user.id}}) == 0) { if (await prisma.category.count({where: {userId: user.sub}}) == 0) {
console.log('Creating sample categories...'); console.log('Creating sample categories...');
categories.push(await prisma.category.create({ categories.push(await prisma.category.create({
data: { data: {
userId: user.id, userId: user.sub,
name: 'Groceries', name: 'Groceries',
color: '#FFBEAC', color: '#FFBEAC',
}, },
@ -34,7 +34,7 @@ export default async function generateSampleData(): Promise<ActionResponse> {
categories.push(await prisma.category.create({ categories.push(await prisma.category.create({
data: { data: {
userId: user.id, userId: user.sub,
name: 'Drugstore items', name: 'Drugstore items',
color: '#9CBCFF', color: '#9CBCFF',
}, },
@ -42,7 +42,7 @@ export default async function generateSampleData(): Promise<ActionResponse> {
categories.push(await prisma.category.create({ categories.push(await prisma.category.create({
data: { data: {
userId: user.id, userId: user.sub,
name: 'Going out', name: 'Going out',
color: '#F1ADFF', color: '#F1ADFF',
}, },
@ -50,7 +50,7 @@ export default async function generateSampleData(): Promise<ActionResponse> {
categories.push(await prisma.category.create({ categories.push(await prisma.category.create({
data: { data: {
userId: user.id, userId: user.sub,
name: 'Random stuff', name: 'Random stuff',
color: '#C1FFA9', color: '#C1FFA9',
}, },
@ -58,7 +58,7 @@ export default async function generateSampleData(): Promise<ActionResponse> {
categories.push(await prisma.category.create({ categories.push(await prisma.category.create({
data: { data: {
userId: user.id, userId: user.sub,
name: 'Salary', name: 'Salary',
color: '#FFF787', color: '#FFF787',
}, },
@ -69,14 +69,14 @@ export default async function generateSampleData(): Promise<ActionResponse> {
console.log(categories); console.log(categories);
// Entities: create sample data // Entities: create sample data
const entities: Entity[] = await prisma.entity.findMany({where: {userId: user.id}}); const entities: Entity[] = await prisma.entity.findMany({where: {userId: user.sub}});
if (await prisma.entity.count({where: {userId: user.id}}) == 0) { if (await prisma.entity.count({where: {userId: user.sub}}) == 0) {
console.log('Creating sample entities...'); console.log('Creating sample entities...');
entities.push(await prisma.entity.create({ entities.push(await prisma.entity.create({
data: { data: {
userId: user.id, userId: user.sub,
name: 'Main Account', name: 'Main Account',
type: EntityType.Account, type: EntityType.Account,
}, },
@ -84,7 +84,7 @@ export default async function generateSampleData(): Promise<ActionResponse> {
entities.push(await prisma.entity.create({ entities.push(await prisma.entity.create({
data: { data: {
userId: user.id, userId: user.sub,
name: 'Company', name: 'Company',
type: EntityType.Entity, type: EntityType.Entity,
}, },
@ -92,7 +92,7 @@ export default async function generateSampleData(): Promise<ActionResponse> {
entities.push(await prisma.entity.create({ entities.push(await prisma.entity.create({
data: { data: {
userId: user.id, userId: user.sub,
name: 'Supermarket 1', name: 'Supermarket 1',
type: EntityType.Entity, type: EntityType.Entity,
}, },
@ -100,7 +100,7 @@ export default async function generateSampleData(): Promise<ActionResponse> {
entities.push(await prisma.entity.create({ entities.push(await prisma.entity.create({
data: { data: {
userId: user.id, userId: user.sub,
name: 'Supermarket 2', name: 'Supermarket 2',
type: EntityType.Entity, type: EntityType.Entity,
}, },
@ -108,7 +108,7 @@ export default async function generateSampleData(): Promise<ActionResponse> {
entities.push(await prisma.entity.create({ entities.push(await prisma.entity.create({
data: { data: {
userId: user.id, userId: user.sub,
name: 'Supermarket 3', name: 'Supermarket 3',
type: EntityType.Entity, type: EntityType.Entity,
}, },
@ -116,7 +116,7 @@ export default async function generateSampleData(): Promise<ActionResponse> {
entities.push(await prisma.entity.create({ entities.push(await prisma.entity.create({
data: { data: {
userId: user.id, userId: user.sub,
name: 'Supermarket 4', name: 'Supermarket 4',
type: EntityType.Entity, type: EntityType.Entity,
}, },
@ -129,21 +129,24 @@ export default async function generateSampleData(): Promise<ActionResponse> {
// Payments: create sample data // Payments: create sample data
console.log('Creating sample payments...'); console.log('Creating sample payments...');
if (await prisma.payment.count({where: {userId: user.id}}) == 0) { if (await prisma.payment.count({where: {userId: user.sub}}) == 0) {
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const date = new Date(); const date = new Date();
date.setDate(1); date.setDate(1);
date.setMonth(date.getMonth() - i); date.setMonth(date.getMonth() - i);
const categoryId =
categories.find((it) => it.name === 'Salary')?.id!;
await prisma.payment.create({ await prisma.payment.create({
data: { data: {
userId: user.id, userId: user.sub,
amount: 200000, amount: 200000,
date: date, date: date,
payorId: entities[1].id, payorId: entities[1].id,
payeeId: entities[0].id, payeeId: entities[0].id,
categoryId: 5, categoryId: categoryId,
createdAt: date, createdAt: date,
updatedAt: date, updatedAt: date,
}, },
@ -166,7 +169,7 @@ export default async function generateSampleData(): Promise<ActionResponse> {
await prisma.payment.create({ await prisma.payment.create({
data: { data: {
userId: user.id, userId: user.sub,
amount: Math.floor( amount: Math.floor(
Math.random() * (maxAmount - minAmount) + minAmount), Math.random() * (maxAmount - minAmount) + minAmount),
date: date, date: date,

View file

@ -1,9 +1,9 @@
import { z } from 'zod'; import { z } from 'zod';
import { ActionResponse } from '@/lib/types/actionResponse'; import { ActionResponse } from '@/lib/types/actionResponse';
import prisma from '@/prisma'; import prisma from '@/prisma';
import { getUser } from '@/auth';
import { URL_SIGN_IN } from '@/lib/constants'; import { URL_SIGN_IN } from '@/lib/constants';
import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema'; import { paymentFormSchema } from '@/lib/form-schemas/paymentFormSchema';
import { auth0 } from '@/lib/auth';
export default async function paymentCreateUpdate({ export default async function paymentCreateUpdate({
id, id,
@ -16,15 +16,15 @@ export default async function paymentCreateUpdate({
}: z.infer<typeof paymentFormSchema>): Promise<ActionResponse> { }: z.infer<typeof paymentFormSchema>): Promise<ActionResponse> {
'use server'; 'use server';
// check that user is logged in const session = await auth0.getSession();
const user = await getUser(); if (!session) {
if (!user) {
return { return {
type: 'error', type: 'error',
message: 'You must be logged in to create/update a payment.', message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN, redirect: URL_SIGN_IN,
}; };
} }
const user = session.user;
// create/update payment // create/update payment
try { try {
@ -52,7 +52,7 @@ export default async function paymentCreateUpdate({
} else { } else {
await prisma.payment.create({ await prisma.payment.create({
data: { data: {
userId: user.id, userId: user.sub,
amount: amount, amount: amount,
date: date, date: date,
payorId: payorId, payorId: payorId,

View file

@ -1,7 +1,7 @@
import { ActionResponse } from '@/lib/types/actionResponse'; import { ActionResponse } from '@/lib/types/actionResponse';
import prisma from '@/prisma'; import prisma from '@/prisma';
import { getUser } from '@/auth';
import { URL_SIGN_IN } from '@/lib/constants'; import { URL_SIGN_IN } from '@/lib/constants';
import { auth0 } from '@/lib/auth';
export default async function paymentDelete(id: number): Promise<ActionResponse> { export default async function paymentDelete(id: number): Promise<ActionResponse> {
'use server'; 'use server';
@ -14,21 +14,21 @@ export default async function paymentDelete(id: number): Promise<ActionResponse>
}; };
} }
// check that user is logged in const session = await auth0.getSession();
const user = await getUser(); if (!session) {
if (!user) {
return { return {
type: 'error', type: 'error',
message: 'You must be logged in to delete a payment.', message: 'You aren\'t signed in.',
redirect: URL_SIGN_IN, redirect: URL_SIGN_IN,
}; };
} }
const user = session.user;
// check that payment is associated with user // check that payment is associated with user
const payment = await prisma.payment.findFirst({ const payment = await prisma.payment.findFirst({
where: { where: {
id: id, id: id,
userId: user.id, userId: user.sub,
}, },
}); });
if (!payment) { if (!payment) {
@ -43,7 +43,7 @@ export default async function paymentDelete(id: number): Promise<ActionResponse>
await prisma.payment.delete({ await prisma.payment.delete({
where: { where: {
id: payment.id, id: payment.id,
userId: user.id, userId: user.sub,
}, },
}, },
); );

View file

@ -1,41 +0,0 @@
import { z } from 'zod';
import { Argon2id } from 'oslo/password';
import { lucia } from '@/auth';
import { cookies } from 'next/headers';
import { signInFormSchema } from '@/lib/form-schemas/signInFormSchema';
import { ActionResponse } from '@/lib/types/actionResponse';
import { URL_HOME } from '@/lib/constants';
import prisma from '@/prisma';
export default async function signIn({username, password}: z.infer<typeof signInFormSchema>): Promise<ActionResponse> {
'use server';
const existingUser = await prisma.user.findFirst({
where: {
username: username.toLowerCase(),
},
});
if (!existingUser) {
return {
type: 'error',
message: 'Incorrect username or password',
};
}
const validPassword = await new Argon2id().verify(existingUser.password, password);
if (!validPassword) {
return {
type: 'error',
message: 'Incorrect username or password',
};
}
const session = await lucia.createSession(existingUser.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return {
type: 'success',
message: 'Signed in successfully',
redirect: URL_HOME,
};
}

View file

@ -1,26 +0,0 @@
import { getSession, lucia } from '@/auth';
import { cookies } from 'next/headers';
import { ActionResponse } from '@/lib/types/actionResponse';
import { URL_SIGN_IN } from '@/lib/constants';
export default async function signOut(): Promise<ActionResponse> {
'use server';
const session = await getSession();
if (!session) {
return {
type: 'error',
message: 'You aren\'t signed in',
};
}
await lucia.invalidateSession(session.id);
const sessionCookie = lucia.createBlankSessionCookie();
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return {
type: 'success',
message: 'Signed out successfully',
redirect: URL_SIGN_IN,
};
}

View file

@ -1,46 +0,0 @@
import { z } from 'zod';
import { Argon2id } from 'oslo/password';
import { generateId } from 'lucia';
import { lucia } from '@/auth';
import { cookies } from 'next/headers';
import { signUpFormSchema } from '@/lib/form-schemas/signUpFormSchema';
import { ActionResponse } from '@/lib/types/actionResponse';
import { URL_HOME } from '@/lib/constants';
import prisma from '@/prisma';
export default async function signUp({username, password}: z.infer<typeof signUpFormSchema>): Promise<ActionResponse> {
'use server';
const hashedPassword = await new Argon2id().hash(password);
const userId = generateId(15);
const existingUser = await prisma.user.findFirst({
where: {
username: username.toLowerCase(),
},
});
if (existingUser) {
return {
type: 'error',
message: 'Username already exists',
};
}
await prisma.user.create({
data: {
id: userId,
username: username,
password: hashedPassword,
},
});
const session = await lucia.createSession(userId, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return {
type: 'success',
message: 'Signed up successfully',
redirect: URL_HOME,
};
}

9
src/lib/auth.ts Normal file
View file

@ -0,0 +1,9 @@
import { Auth0Client } from "@auth0/nextjs-auth0/server"
export const auth0 = new Auth0Client({
appBaseUrl: process.env.AUTH0_BASE_URL,
domain: process.env.AUTH0_URL,
secret: process.env.AUTH0_SECRET,
clientId: process.env.AUTH0_CLIENT_ID,
clientSecret: process.env.AUTH0_CLIENT_SECRET,
})

View file

@ -1,7 +1,6 @@
// auth urls export const URL_SIGN_IN = `/auth/login`;
export const URL_AUTH = '/auth'; export const URL_SIGN_OUT = `/auth/logout`;
export const URL_SIGN_IN = `${URL_AUTH}/signin`;
export const URL_SIGN_UP = `${URL_AUTH}/signup`;
// main urls // main urls
export const URL_HOME = '/'; export const URL_HOME = '/';

View file

@ -1,6 +0,0 @@
import { z } from 'zod';
export const signInFormSchema = z.object({
username: z.string().min(3).max(16),
password: z.string().min(8).max(255),
});

View file

@ -1,10 +0,0 @@
import { z } from 'zod';
export const signUpFormSchema = z.object({
username: z.string().min(3).max(16),
password: z.string().min(8).max(255),
confirm: z.string().min(8).max(255),
}).refine(data => data.password === data.confirm, {
message: 'Passwords do not match',
path: ['confirm'],
});

View file

@ -1,27 +1,22 @@
import type { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { auth0 } from '@/lib/auth';
import { URL_AUTH, URL_HOME, URL_SIGN_IN } from './lib/constants';
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {
try {
// get session id from cookies return await auth0.middleware(request);
const sessionId = request.cookies.get('auth_session')?.value ?? null; } catch (error) {
console.error("Auth0 middleware error:", error);
// redirect to home if user is already authenticated
if (request.nextUrl.pathname.startsWith(URL_AUTH) && sessionId) {
return NextResponse.redirect(new URL(URL_HOME, request.url));
} }
// redirect to sign in if user is not authenticated
if (!request.nextUrl.pathname.startsWith(URL_AUTH) && !sessionId) {
return NextResponse.redirect(new URL(URL_SIGN_IN, request.url));
}
return NextResponse.next();
} }
export const config = { export const config = {
matcher: [ matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)', /*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt (metadata files)
*/
"/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
], ],
}; }

View file

@ -29,7 +29,8 @@
}, },
"types": [ "types": [
"@serwist/next/typings" "@serwist/next/typings"
] ],
"target": "ES2017"
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",