├── .env.example ├── .eslintrc.json ├── app ├── globals.css ├── favicon.ico ├── login │ ├── admin │ │ ├── actions.ts │ │ └── page.tsx │ └── account │ │ ├── actions.ts │ │ └── page.tsx ├── layout.tsx ├── dashboard │ ├── account │ │ ├── attendance │ │ │ ├── actions.ts │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── actions.ts │ │ └── page.tsx │ └── admin │ │ ├── actions.ts │ │ └── page.tsx ├── attend │ └── [data] │ │ ├── actions.ts │ │ └── page.tsx └── page.tsx ├── public ├── flask.png ├── mysql.png ├── nextjs.png ├── prisma.png ├── cara-kerja.png ├── postgress.png ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── site.webmanifest └── one-scan.svg ├── next.config.mjs ├── postcss.config.mjs ├── lib └── utils.ts ├── stores ├── UserContext.ts └── UserFullContext.ts ├── components ├── loading.tsx ├── footer.tsx ├── ui │ ├── popover.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── table.tsx │ └── dialog.tsx ├── addUserDialog.tsx ├── addAccountDialog.tsx ├── patchUserDialog.tsx └── patchAccountDialog.tsx ├── components.json ├── .gitignore ├── .github └── workflows │ ├── deploy.yaml │ ├── test-build.yaml │ └── build-image.yaml ├── tsconfig.json ├── Dockerfile ├── README.md ├── package.json ├── tailwind.config.ts └── LICENSE /.env.example: -------------------------------------------------------------------------------- 1 | BASE_API_URL=http://127.0.0.1:3001 -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Absenin/absenin-web/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/flask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Absenin/absenin-web/HEAD/public/flask.png -------------------------------------------------------------------------------- /public/mysql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Absenin/absenin-web/HEAD/public/mysql.png -------------------------------------------------------------------------------- /public/nextjs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Absenin/absenin-web/HEAD/public/nextjs.png -------------------------------------------------------------------------------- /public/prisma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Absenin/absenin-web/HEAD/public/prisma.png -------------------------------------------------------------------------------- /public/cara-kerja.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Absenin/absenin-web/HEAD/public/cara-kerja.png -------------------------------------------------------------------------------- /public/postgress.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Absenin/absenin-web/HEAD/public/postgress.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Absenin/absenin-web/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Absenin/absenin-web/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Absenin/absenin-web/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Absenin/absenin-web/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Absenin/absenin-web/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /stores/UserContext.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from "@/app/dashboard/account/actions"; 2 | import { Dispatch, SetStateAction, createContext } from "react"; 3 | 4 | export const UserContext = createContext<[IUser | null, state: Dispatch>]>([null, () => { }]); -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /stores/UserFullContext.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from "@/app/dashboard/account/actions"; 2 | import { Dispatch, SetStateAction, createContext } from "react"; 3 | 4 | export const FullUserContext = createContext<[IUser | null, state: Dispatch>]>([null, () => { }]); -------------------------------------------------------------------------------- /components/loading.tsx: -------------------------------------------------------------------------------- 1 | import { LoaderCircle } from 'lucide-react'; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy CI 2 | on: 3 | push: 4 | branches: [main] 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 11 | env: 12 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} 13 | - name: "Deploy to Vercel" 14 | run: | 15 | npx vercel --token ${VERCEL_TOKEN} --prod 16 | env: 17 | VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} 18 | VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} 19 | VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /components/footer.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default function Footer() { 4 | return ( 5 |
6 |
7 |

Copyright Vann-Dev © {new Date().getFullYear()}

8 |

9 | Repository Licensed Under BSD-3-Clause license, Modification is possible while copyright and license notices must be preserved. 10 |

11 |
12 | 13 |
14 | ) 15 | } -------------------------------------------------------------------------------- /.github/workflows/test-build.yaml: -------------------------------------------------------------------------------- 1 | name: Test Build 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | 11 | - name: Install Node.js 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | 16 | - name: Install pnpm 17 | uses: pnpm/action-setup@v3 18 | with: 19 | version: 8 20 | 21 | - name: Check out code 22 | uses: actions/checkout@8459bc0c7e3759cdf591f513d9f141a95fef0a8f 23 | 24 | - name: Install dependencies 25 | run: pnpm install 26 | 27 | - name: Build 28 | run: pnpm run build -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/hazmi35/node:21-alpine as build-stage 2 | 3 | WORKDIR /tmp/build 4 | 5 | RUN corepack enable && corepack prepare pnpm@latest 6 | 7 | COPY . . 8 | 9 | RUN pnpm install --frozen-lockfile 10 | 11 | RUN pnpm run build 12 | 13 | RUN pnpm prune --production 14 | 15 | FROM ghcr.io/hazmi35/node:21-alpine 16 | 17 | RUN corepack enable && corepack prepare pnpm@latest 18 | 19 | COPY --from=build-stage /tmp/build/package.json . 20 | COPY --from=build-stage /tmp/build/pnpm-lock.yaml . 21 | COPY --from=build-stage /tmp/build/node_modules ./node_modules 22 | COPY --from=build-stage /tmp/build/.next .next 23 | COPY --from=build-stage /tmp/build/next.config.mjs ./next.config.mjs 24 | COPY --from=build-stage /tmp/build/public ./public 25 | 26 | CMD ["pnpm", "run", "start"] -------------------------------------------------------------------------------- /app/login/admin/actions.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { cookies } from "next/headers" 4 | 5 | export async function loginAdmin(password: string): Promise<{ valid: boolean, session: string }> { 6 | const cookieStore = cookies() 7 | 8 | const res = await fetch(`${process.env.BASE_API_URL}/login/admin`, { 9 | method: "POST", 10 | headers: { 11 | "Content-Type": "application/json", 12 | }, 13 | body: JSON.stringify({ 14 | password: password 15 | }), 16 | }); 17 | 18 | if (!res.ok) { 19 | return { 20 | valid: false, 21 | session: "" 22 | } 23 | } 24 | 25 | const json = await res.json(); 26 | 27 | cookieStore.set("session", json.session) 28 | 29 | return { 30 | valid: res.ok, 31 | session: json.session 32 | } 33 | } -------------------------------------------------------------------------------- /app/login/account/actions.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { cookies } from "next/headers" 4 | 5 | export async function loginAccount(email: string, password: string): Promise<{ valid: boolean, session: string }> { 6 | const cookieStore = cookies() 7 | 8 | const res = await fetch(`${process.env.BASE_API_URL}/login/account`, { 9 | method: "POST", 10 | headers: { 11 | "Content-Type": "application/json", 12 | }, 13 | body: JSON.stringify({ 14 | email: email, 15 | password: password 16 | }), 17 | }); 18 | 19 | if (!res.ok) { 20 | return { 21 | valid: false, 22 | session: "" 23 | } 24 | } 25 | 26 | const json = await res.json(); 27 | 28 | cookieStore.set("session", json.session) 29 | 30 | return { 31 | valid: res.ok, 32 | session: json.session 33 | } 34 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # How to deploy 2 | 3 | ## Clone the repo 4 | ``` 5 | git clone https://github.com/Absenin/absenin-web 6 | ``` 7 | 8 | ## Install required deps 9 | > pnpm 10 | ``` 11 | pnpm install 12 | ``` 13 | > npm 14 | ``` 15 | npm install 16 | ``` 17 | > yarn 18 | ``` 19 | yarn install 20 | ``` 21 | 22 | ## Add the .env file 23 | > .env schema in here https://github.com/Absenin/absenin-web/blob/main/.env.example 24 | 25 | ## Build the project 26 | ``` 27 | pnpm run build 28 | ``` 29 | > npm 30 | ``` 31 | npm run build 32 | ``` 33 | > yarn 34 | ``` 35 | yarn run build 36 | ``` 37 | 38 | ## Start the project 39 | ``` 40 | pnpm run start 41 | ``` 42 | > npm 43 | ``` 44 | npm run start 45 | ``` 46 | > yarn 47 | ``` 48 | yarn run start 49 | ``` 50 | 51 | ___ 52 | ## Star History 53 | 54 | [![Star History Chart](https://api.star-history.com/svg?repos=Absenin/absenin-web&type=Date)](https://star-history.com/#Absenin/absenin-web&Date) 55 | 56 | # Repository Licensed Under BSD-3-Clause license, Modification is possible while copyright and license notices must be preserved. 57 | 58 | Copyright © 2024 Vann-Dev 59 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Poppins } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const PoppinsFont = Poppins({ subsets: ["latin"], weight: "400" }); 6 | 7 | export const metadata: Metadata = { 8 | title: "Absenin", 9 | description: "Absensi Berbasis QR dengan authentication multilevel 🔥", 10 | icons: [ 11 | { 12 | rel: "icon", 13 | href: "/favicon.ico", 14 | url: "/favicon.ico", 15 | }, 16 | { 17 | rel: "apple-touch-icon", 18 | href: "/apple-touch-icon.png", 19 | url: "/apple-touch-icon.png", 20 | }, 21 | { 22 | rel: "manifest", 23 | href: "/site.webmanifest", 24 | url: "/site.webmanifest", 25 | }, 26 | ] 27 | }; 28 | 29 | export default function RootLayout({ 30 | children, 31 | }: Readonly<{ 32 | children: React.ReactNode; 33 | }>) { 34 | return ( 35 | 36 | 37 |
38 | {children} 39 |
40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "absenin-web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@fingerprintjs/fingerprintjs": "^4.3.0", 13 | "@radix-ui/react-dialog": "^1.0.5", 14 | "@radix-ui/react-popover": "^1.0.7", 15 | "@radix-ui/react-slot": "^1.0.2", 16 | "class-variance-authority": "^0.7.0", 17 | "clsx": "^2.1.1", 18 | "date-fns": "^3.6.0", 19 | "exceljs": "^4.4.0", 20 | "lucide-react": "^0.378.0", 21 | "next": "14.2.3", 22 | "qrcode": "^1.5.3", 23 | "react": "^18", 24 | "react-day-picker": "^8.10.1", 25 | "react-dom": "^18", 26 | "tailwind-merge": "^2.3.0", 27 | "tailwindcss-animate": "^1.0.7" 28 | }, 29 | "devDependencies": { 30 | "@types/node": "^20", 31 | "@types/qrcode": "^1.5.5", 32 | "@types/react": "^18", 33 | "@types/react-dom": "^18", 34 | "@types/request-ip": "^0.0.41", 35 | "eslint": "^8", 36 | "eslint-config-next": "14.2.3", 37 | "postcss": "^8", 38 | "tailwindcss": "^3.4.1", 39 | "typescript": "^5" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | './pages/**/*.{ts,tsx}', 7 | './components/**/*.{ts,tsx}', 8 | './app/**/*.{ts,tsx}', 9 | './src/**/*.{ts,tsx}', 10 | ], 11 | theme: { 12 | container: { 13 | center: true, 14 | padding: "1.5rem", 15 | }, 16 | extend: { 17 | colors: { 18 | "background": "#FEF8F9", 19 | "primary": "#E62146", 20 | "secondary": "#F1C885", 21 | "text": "#24040B", 22 | "accent": "#ECE655", 23 | }, 24 | keyframes: { 25 | "accordion-down": { 26 | from: { height: "0" }, 27 | to: { height: "var(--radix-accordion-content-height)" }, 28 | }, 29 | "accordion-up": { 30 | from: { height: "var(--radix-accordion-content-height)" }, 31 | to: { height: "0" }, 32 | }, 33 | }, 34 | animation: { 35 | "accordion-down": "accordion-down 0.2s ease-out", 36 | "accordion-up": "accordion-up 0.2s ease-out", 37 | }, 38 | }, 39 | }, 40 | plugins: [require("tailwindcss-animate")], 41 | } satisfies Config 42 | 43 | export default config -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /.github/workflows/build-image.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | on: 3 | release: 4 | types: [created] 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Check out code 11 | uses: actions/checkout@8459bc0c7e3759cdf591f513d9f141a95fef0a8f 12 | 13 | - name: Lowercase repository 14 | id: repository 15 | uses: ASzc/change-string-case-action@ccb130a4e483d3e86287289183704dc9bf53e77e 16 | with: 17 | string: ${{ github.repository }} 18 | 19 | - name: Create Tags 20 | id: tags 21 | uses: ASzc/change-string-case-action@ccb130a4e483d3e86287289183704dc9bf53e77e 22 | with: 23 | string: ${{ github.event_name == 'release' && 'latest' || github.ref_name }} 24 | 25 | - name: Log in to GitHub Container Registry 26 | uses: docker/login-action@5f4866a30a54f16a52d2ecb4a3898e9e424939cf 27 | if: ${{ github.event_name != 'pull_request' }} 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.repository_owner }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | - name: Build and push Docker image 34 | uses: docker/build-push-action@eb539f44b153603ccbfbd98e2ab9d4d0dcaf23a4 35 | with: 36 | context: . 37 | push: true 38 | tags: ghcr.io/${{ steps.repository.outputs.lowercase }}:${{ steps.tags.outputs.lowercase }} -------------------------------------------------------------------------------- /app/dashboard/account/attendance/actions.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { cookies } from "next/headers"; 4 | 5 | export interface IAttendance { 6 | data: { 7 | created_at: string, 8 | user: { 9 | name: string, 10 | nim: string, 11 | } 12 | id: string 13 | }[], 14 | id: string 15 | } 16 | 17 | export async function getAttendance(timestamp: number): Promise { 18 | const cookieStore = cookies() 19 | 20 | const res = await fetch(`${process.env.BASE_API_URL}/date/${Math.round(timestamp / 1000)}`, { 21 | method: "GET", 22 | headers: { 23 | "Cookie": `session=${cookieStore.get("session")?.value}` 24 | }, 25 | }); 26 | 27 | if (!res.ok) return false; 28 | 29 | const json = await res.json(); 30 | 31 | return json; 32 | } 33 | 34 | export async function postDate(date: number) { 35 | const cookieStore = cookies() 36 | 37 | const res = await fetch(`${process.env.BASE_API_URL}/date`, { 38 | method: "POST", 39 | headers: { 40 | "Content-Type": "application/json", 41 | "Cookie": `session=${cookieStore.get("session")?.value}` 42 | }, 43 | body: JSON.stringify({ 44 | date: Math.round(date / 1000) 45 | }), 46 | }); 47 | 48 | return res.ok; 49 | } 50 | 51 | export async function urlSafeBase64Encode(str: string) { 52 | return btoa(str) 53 | .replace(/\+/g, '-') 54 | .replace(/\//g, '_') 55 | .replace(/=/g, ''); 56 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Absenin 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /app/dashboard/account/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useEffect, useState } from "react"; 4 | import { IUser, getUsers } from "./actions"; 5 | import { UserContext } from "@/stores/UserContext"; 6 | import Loading from "@/components/loading"; 7 | import { useRouter } from "next/navigation"; 8 | import { FullUserContext } from "@/stores/UserFullContext"; 9 | 10 | export default function Layout({ 11 | children 12 | }: { 13 | children: React.ReactNode 14 | }) { 15 | const router = useRouter(); 16 | const [userData, setUserData] = useState(null); 17 | const [fullUserData, setFullUserData] = useState(null); 18 | const [fetched, setFetched] = useState(false); 19 | 20 | useEffect(() => { 21 | if (!fetched) { 22 | getUsers().then((data) => { 23 | if (!data) { 24 | return router.push("/login/account"); 25 | } 26 | 27 | if (data) { 28 | setUserData(data); 29 | setFullUserData(data); 30 | setFetched(true); 31 | } 32 | }); 33 | } 34 | }, [fetched, router]) 35 | 36 | if (!userData) { 37 | return ( 38 | 39 | ) 40 | } 41 | 42 | return ( 43 | 44 | 45 | {children} 46 | 47 | 48 | ) 49 | } -------------------------------------------------------------------------------- /app/attend/[data]/actions.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { cookies } from "next/headers" 4 | import { headers } from 'next/headers' 5 | 6 | export async function getSession(dateId: string, fingerPrint: string) { 7 | const cookieStore = cookies() 8 | const header = headers() 9 | const ip = (header.get('x-forwarded-for') ?? '127.0.0.1').split(',')[0] 10 | 11 | const res = await fetch(`${process.env.BASE_API_URL}/attendance`, { 12 | method: "POST", 13 | headers: { 14 | "Content-Type": "application/json", 15 | }, 16 | body: JSON.stringify({ 17 | ip: ip, 18 | fingerprint: fingerPrint, 19 | date_id: dateId 20 | }), 21 | }); 22 | 23 | if (!res.ok) return false; 24 | 25 | const json = await res.json(); 26 | 27 | cookieStore.set("user_attendance", json.user_attendance) 28 | cookieStore.set("hash_attendance", json.hash_attendance) 29 | 30 | return res.ok; 31 | } 32 | 33 | export async function urlSafeBase64Decode(str: string) { 34 | let paddedStr = str + '='.repeat((4 - str.length % 4) % 4); 35 | return atob(paddedStr 36 | .replace(/\-/g, '+') 37 | .replace(/_/g, '/')); 38 | } 39 | 40 | export async function putAttendance(nim: string): Promise { 41 | const cookieStore = cookies() 42 | 43 | const res = await fetch(`${process.env.BASE_API_URL}/attendance`, { 44 | method: "PUT", 45 | headers: { 46 | "Content-Type": "application/json", 47 | "Cookie": `hash_attendance=${cookieStore.get("hash_attendance")?.value};user_attendance=${cookieStore.get("user_attendance")?.value}` 48 | }, 49 | body: JSON.stringify({ 50 | nim 51 | }), 52 | }); 53 | 54 | const json = await res.json(); 55 | 56 | if (!res.ok) { 57 | return json.error 58 | } 59 | 60 | return true 61 | } -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "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", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /app/dashboard/account/actions.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | export interface IUser { 4 | data: { 5 | created_at: string, 6 | name: string, 7 | nim: string, 8 | id: string 9 | }[] 10 | } 11 | 12 | import { cookies } from "next/headers"; 13 | 14 | export async function getUsers(): Promise { 15 | const cookieStore = cookies() 16 | 17 | const res = await fetch(`${process.env.BASE_API_URL}/user`, { 18 | method: "GET", 19 | headers: { 20 | "Content-Type": "application/json", 21 | "Cookie": `session=${cookieStore.get("session")?.value}` 22 | }, 23 | }); 24 | 25 | if (!res.ok) return false; 26 | 27 | const json = await res.json(); 28 | 29 | return json; 30 | } 31 | 32 | export async function postUser(nim: string, name: string) { 33 | const cookieStore = cookies() 34 | 35 | const res = await fetch(`${process.env.BASE_API_URL}/user`, { 36 | method: "POST", 37 | headers: { 38 | "Content-Type": "application/json", 39 | "Cookie": `session=${cookieStore.get("session")?.value}` 40 | }, 41 | body: JSON.stringify({ 42 | nim, 43 | name 44 | }), 45 | }); 46 | 47 | if (!res.ok) return false; 48 | 49 | const json = await res.json(); 50 | 51 | return json; 52 | } 53 | 54 | export async function deleteUser(id: string) { 55 | const cookieStore = cookies() 56 | 57 | const res = await fetch(`${process.env.BASE_API_URL}/user/${id}`, { 58 | method: "DELETE", 59 | headers: { 60 | "Content-Type": "application/json", 61 | "Cookie": `session=${cookieStore.get("session")?.value}` 62 | }, 63 | }); 64 | 65 | return res.ok; 66 | } 67 | 68 | export async function patchUser(id: string, nim: string, name: string) { 69 | const cookieStore = cookies() 70 | 71 | const res = await fetch(`${process.env.BASE_API_URL}/user/${id}`, { 72 | method: "PATCH", 73 | headers: { 74 | "Content-Type": "application/json", 75 | "Cookie": `session=${cookieStore.get("session")?.value}` 76 | }, 77 | body: JSON.stringify({ 78 | nim, 79 | name 80 | }), 81 | }); 82 | 83 | return res.ok; 84 | } -------------------------------------------------------------------------------- /app/dashboard/admin/actions.ts: -------------------------------------------------------------------------------- 1 | "use server" 2 | 3 | import { cookies } from "next/headers" 4 | 5 | export interface IData { 6 | data: { 7 | id: string 8 | email: string 9 | createdAt: string 10 | }[] 11 | } 12 | 13 | export async function getAccounts(): Promise { 14 | const cookieStore = cookies() 15 | 16 | const res = await fetch(`${process.env.BASE_API_URL}/account`, { 17 | method: "GET", 18 | headers: { 19 | "Content-Type": "application/json", 20 | "Cookie": `session=${cookieStore.get("session")?.value}` 21 | }, 22 | }); 23 | 24 | if (!res.ok) return false; 25 | 26 | const json = await res.json(); 27 | 28 | return json; 29 | } 30 | 31 | export async function postAccount(email: string, password: string) { 32 | const cookieStore = cookies() 33 | 34 | const res = await fetch(`${process.env.BASE_API_URL}/account`, { 35 | method: "POST", 36 | headers: { 37 | "Content-Type": "application/json", 38 | "Cookie": `session=${cookieStore.get("session")?.value}` 39 | }, 40 | body: JSON.stringify({ 41 | email, 42 | password 43 | }), 44 | }); 45 | 46 | return res.ok; 47 | } 48 | 49 | export async function deleteAccount(id: string) { 50 | const cookieStore = cookies() 51 | 52 | const res = await fetch(`${process.env.BASE_API_URL}/account/${id}`, { 53 | method: "DELETE", 54 | headers: { 55 | "Content-Type": "application/json", 56 | "Cookie": `session=${cookieStore.get("session")?.value}` 57 | }, 58 | }); 59 | 60 | return res.ok; 61 | } 62 | 63 | export async function patchAccount(id: string, email: string, password: string | null) { 64 | const cookieStore = cookies() 65 | 66 | const body = {} as { email?: string, password?: string } 67 | 68 | if (email) { 69 | body.email = email 70 | } 71 | 72 | if (password) { 73 | body.password = password 74 | } 75 | 76 | const res = await fetch(`${process.env.BASE_API_URL}/account/${id}`, { 77 | method: "PATCH", 78 | headers: { 79 | "Content-Type": "application/json", 80 | "Cookie": `session=${cookieStore.get("session")?.value}` 81 | }, 82 | body: JSON.stringify(body), 83 | }); 84 | 85 | return res.ok; 86 | } -------------------------------------------------------------------------------- /components/ui/calendar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ChevronLeft, ChevronRight } from "lucide-react" 5 | import { DayPicker } from "react-day-picker" 6 | 7 | import { cn } from "@/lib/utils" 8 | import { buttonVariants } from "@/components/ui/button" 9 | 10 | export type CalendarProps = React.ComponentProps 11 | 12 | function Calendar({ 13 | className, 14 | classNames, 15 | showOutsideDays = true, 16 | ...props 17 | }: CalendarProps) { 18 | return ( 19 | , 58 | IconRight: ({ ...props }) => , 59 | }} 60 | {...props} 61 | /> 62 | ) 63 | } 64 | Calendar.displayName = "Calendar" 65 | 66 | export { Calendar } 67 | -------------------------------------------------------------------------------- /app/login/admin/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link"; 4 | import { FormEvent, useState } from "react"; 5 | import { loginAdmin } from "./actions"; 6 | import { useRouter } from "next/navigation"; 7 | import Loading from "@/components/loading"; 8 | 9 | export default function Page() { 10 | const [password, setPassword] = useState(""); 11 | const [error, setError] = useState(""); 12 | const router = useRouter() 13 | const [loading, setLoading] = useState(false); 14 | 15 | async function handleSubmit(e: FormEvent) { 16 | e.preventDefault() 17 | if (!password) { 18 | return setError("Password tidak boleh kosong"); 19 | } 20 | 21 | setError(""); 22 | 23 | setLoading(true); 24 | const response = await loginAdmin(password) 25 | setLoading(false); 26 | 27 | if (!response.valid) { 28 | return setError("Password salah"); 29 | } 30 | 31 | router.push("/dashboard/admin") 32 | } 33 | 34 | if (loading) return 35 | 36 | return ( 37 |
38 |
handleSubmit(e)} className="flex flex-col items-center w-full gap-y-10 container max-w-96"> 39 |

Login Admin

40 |
41 | setPassword(e.target.value)} 46 | className="w-full px-4 py-2 rounded-lg border border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" 47 | /> 48 | { 49 | error &&

{error}

50 | } 51 |
52 |
53 | 59 | 60 | Login Akun 61 | 62 |
63 |
64 |
65 | ) 66 | } -------------------------------------------------------------------------------- /components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | 93 | )) 94 | TableCell.displayName = "TableCell" 95 | 96 | const TableCaption = React.forwardRef< 97 | HTMLTableCaptionElement, 98 | React.HTMLAttributes 99 | >(({ className, ...props }, ref) => ( 100 |
105 | )) 106 | TableCaption.displayName = "TableCaption" 107 | 108 | export { 109 | Table, 110 | TableHeader, 111 | TableBody, 112 | TableFooter, 113 | TableHead, 114 | TableRow, 115 | TableCell, 116 | TableCaption, 117 | } 118 | -------------------------------------------------------------------------------- /app/login/account/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link"; 4 | import { FormEvent, useState } from "react"; 5 | import { loginAccount } from "./actions"; 6 | import { useRouter } from "next/navigation"; 7 | import Loading from "@/components/loading"; 8 | 9 | export default function Page() { 10 | const [email, setEmail] = useState(""); 11 | const [password, setPassword] = useState(""); 12 | const [error, setError] = useState(""); 13 | const [loading, setLoading] = useState(false); 14 | const router = useRouter() 15 | 16 | async function handleSubmit(e: FormEvent) { 17 | e.preventDefault() 18 | if (!email || !password) { 19 | return setError("Email dan password tidak boleh kosong"); 20 | } 21 | 22 | if (!/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/g.test(email)) { 23 | return setError("Email tidak valid"); 24 | } 25 | 26 | setError(""); 27 | 28 | setLoading(true); 29 | const response = await loginAccount(email, password) 30 | setLoading(false); 31 | 32 | if (!response.valid) { 33 | return setError("Email atau password salah"); 34 | } 35 | 36 | router.push("/dashboard/account") 37 | } 38 | 39 | if (loading) return 40 | 41 | return ( 42 |
43 |
handleSubmit(e)} className="flex flex-col items-center w-full gap-y-10 container max-w-96"> 44 |

Login Account

45 |
46 | setEmail(e.target.value)} 51 | className="w-full px-4 py-2 rounded-lg border border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" 52 | /> 53 | setPassword(e.target.value)} 58 | className="w-full px-4 py-2 rounded-lg border border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" 59 | /> 60 | { 61 | error &&

{error}

62 | } 63 |
64 |
65 | 71 | 72 | Login Admin 73 | 74 |
75 |
76 |
77 | ) 78 | } -------------------------------------------------------------------------------- /app/attend/[data]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link"; 4 | import { useEffect, useState } from "react"; 5 | import { useParams, useRouter } from "next/navigation"; 6 | import { getSession, putAttendance, urlSafeBase64Decode } from "./actions"; 7 | import Loading from "@/components/loading"; 8 | import FingerPrint from "@fingerprintjs/fingerprintjs" 9 | import RequestIp from "request-ip"; 10 | import { LoaderCircle } from "lucide-react"; 11 | 12 | export default function Page() { 13 | const router = useRouter(); 14 | const params = useParams() as { data: string }; 15 | const [nim, setNim] = useState(""); 16 | const [error, setError] = useState(""); 17 | const [valid, setValid] = useState(false); 18 | const [loading, setLoading] = useState(false); 19 | const [attended, setAttended] = useState(false); 20 | 21 | useEffect(() => { 22 | validate(params.data); 23 | }, []) 24 | 25 | async function validate(data: string) { 26 | const decodedData = await urlSafeBase64Decode(data).catch(() => null); 27 | 28 | if (!decodedData) { 29 | return router.push("https://www.youtube.com/watch?v=dQw4w9WgXcQ") 30 | } 31 | 32 | const dataJson = JSON.parse(decodedData) as { dateId: string, expiredAt: number }; 33 | 34 | if (dataJson.expiredAt < Date.now()) { 35 | return router.push("https://www.youtube.com/watch?v=dQw4w9WgXcQ") 36 | } 37 | 38 | const fp = await FingerPrint.load() 39 | 40 | const ready = await getSession(dataJson.dateId, (await fp.get()).visitorId) 41 | 42 | if (!ready) { 43 | return router.push("https://www.youtube.com/watch?v=dQw4w9WgXcQ") 44 | } 45 | 46 | setValid(true) 47 | } 48 | 49 | async function handleSubmit() { 50 | setError("") 51 | setLoading(true) 52 | putAttendance(nim).then((res) => { 53 | if (res === true) { 54 | setAttended(true) 55 | } else { 56 | setError(res) 57 | } 58 | 59 | setLoading(false) 60 | }) 61 | } 62 | 63 | if (!valid) return 64 | 65 | if (attended) return ( 66 |
67 |

Anda sudah hadir

68 |
69 | ) 70 | 71 | return ( 72 |
73 |
74 |

Absensi

75 |
76 | setNim(e.target.value)} 81 | className="w-full px-4 py-2 rounded-lg border border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" 82 | /> 83 | { 84 | error &&

{error}

85 | } 86 |
87 |
88 | 98 |
99 |
100 |
101 | ) 102 | } -------------------------------------------------------------------------------- /components/addUserDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogClose, 4 | DialogContent, 5 | DialogDescription, 6 | DialogFooter, 7 | DialogHeader, 8 | DialogTitle, 9 | } from "@/components/ui/dialog" 10 | import { FormEvent, useState } from "react"; 11 | import { postAccount } from "@/app/dashboard/admin/actions"; 12 | import { LoaderCircle } from 'lucide-react'; 13 | import { postUser } from "@/app/dashboard/account/actions"; 14 | 15 | export default function AddUserDialog({ open, setDialogOpen }: { open: boolean, setDialogOpen: (open: boolean) => void }) { 16 | const [nim, setNim] = useState(""); 17 | const [nama, setNama] = useState(""); 18 | const [error, setError] = useState(""); 19 | const [loading, setLoading] = useState(false); 20 | 21 | async function addUser(e: FormEvent) { 22 | e.preventDefault() 23 | if (!nim || !nama) { 24 | return setError("NIM dan nama tidak boleh kosong"); 25 | } 26 | 27 | setError(""); 28 | 29 | setLoading(true); 30 | 31 | const valid = await postUser(nim, nama) 32 | 33 | setLoading(false); 34 | 35 | if (!valid) { 36 | return setError("Ada masalah saat menambahkan user"); 37 | } 38 | 39 | setDialogOpen(false); 40 | setNim(""); 41 | setNama(""); 42 | window.location.reload(); 43 | } 44 | 45 | return ( 46 | 47 | 48 | 49 | Tambahkan User 50 | 51 |
addUser(e)} className="flex flex-col gap-y-6"> 52 | 53 | setNim(e.target.value)} 58 | className="w-full px-4 py-2 rounded-lg border border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" 59 | /> 60 | setNama(e.target.value)} 65 | className="w-full px-4 py-2 rounded-lg border border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" 66 | /> 67 | { 68 | error &&

{error}

69 | } 70 |
71 | 77 | 86 |
87 |
88 |
89 |
90 |
91 |
92 | ) 93 | } -------------------------------------------------------------------------------- /components/addAccountDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogClose, 4 | DialogContent, 5 | DialogDescription, 6 | DialogFooter, 7 | DialogHeader, 8 | DialogTitle, 9 | } from "@/components/ui/dialog" 10 | import { FormEvent, useState } from "react"; 11 | import { postAccount } from "@/app/dashboard/admin/actions"; 12 | import { LoaderCircle } from 'lucide-react'; 13 | 14 | export default function AddAccountDialog({ open, setDialogOpen }: { open: boolean, setDialogOpen: (open: boolean) => void }) { 15 | const [email, setEmail] = useState(""); 16 | const [password, setPassword] = useState(""); 17 | const [error, setError] = useState(""); 18 | const [loading, setLoading] = useState(false); 19 | 20 | async function addAccount(e: FormEvent) { 21 | e.preventDefault() 22 | if (!email || !password) { 23 | return setError("Email dan password tidak boleh kosong"); 24 | } 25 | 26 | if (!/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/g.test(email)) { 27 | return setError("Email tidak valid"); 28 | } 29 | 30 | setError(""); 31 | 32 | setLoading(true); 33 | 34 | const valid = await postAccount(email, password) 35 | 36 | setLoading(false); 37 | 38 | if (!valid) { 39 | return setError("Ada masalah saat menambahkan akun"); 40 | } 41 | 42 | setDialogOpen(false); 43 | setEmail(""); 44 | setPassword(""); 45 | window.location.reload(); 46 | } 47 | 48 | return ( 49 | 50 | 51 | 52 | Tambahkan Akun 53 | 54 |
addAccount(e)} className="flex flex-col gap-y-6"> 55 | setEmail(e.target.value)} 60 | className="w-full px-4 py-2 rounded-lg border border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" 61 | /> 62 | setPassword(e.target.value)} 67 | className="w-full px-4 py-2 rounded-lg border border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" 68 | /> 69 | { 70 | error &&

{error}

71 | } 72 |
73 | 79 | 88 |
89 |
90 |
91 |
92 |
93 |
94 | ) 95 | } -------------------------------------------------------------------------------- /components/patchUserDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogContent, 4 | DialogDescription, 5 | DialogHeader, 6 | DialogTitle, 7 | } from "@/components/ui/dialog" 8 | import { FormEvent, useEffect, useState } from "react"; 9 | import { patchAccount } from "@/app/dashboard/admin/actions"; 10 | import { LoaderCircle } from 'lucide-react'; 11 | import { patchUser } from "@/app/dashboard/account/actions"; 12 | 13 | export default function PatchUserDialog({ id, nimDefault, nameDefault, open, setDialogOpen }: { id: string, nimDefault: string, nameDefault: string, open: boolean, setDialogOpen: (open: boolean) => void }) { 14 | const [nim, setNim] = useState(""); 15 | const [name, setName] = useState(""); 16 | const [error, setError] = useState(""); 17 | const [loading, setLoading] = useState(false); 18 | 19 | useEffect(() => { 20 | setNim(nimDefault); 21 | setName(nameDefault); 22 | }, [nimDefault, nameDefault]) 23 | 24 | async function editUser(e: FormEvent) { 25 | e.preventDefault() 26 | if (!nim || !name) { 27 | return setError("NIM dan nama tidak boleh kosong"); 28 | } 29 | 30 | setError(""); 31 | 32 | setLoading(true); 33 | 34 | const valid = await patchUser(id, nim, name) 35 | 36 | setLoading(false); 37 | 38 | if (!valid) { 39 | return setError("Ada masalah saat mengedit user"); 40 | } 41 | 42 | setDialogOpen(false); 43 | setNim(""); 44 | setName(""); 45 | window.location.reload(); 46 | } 47 | 48 | return ( 49 | 50 | 51 | 52 | Edit User 53 | 54 |
editUser(e)} className="flex flex-col gap-y-6"> 55 | setNim(e.target.value)} 60 | className="w-full px-4 py-2 rounded-lg border border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" 61 | /> 62 | setName(e.target.value)} 67 | className="w-full px-4 py-2 rounded-lg border border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" 68 | /> 69 | { 70 | error &&

{error}

71 | } 72 |
73 | 79 | 88 |
89 |
90 |
91 |
92 |
93 |
94 | ) 95 | } -------------------------------------------------------------------------------- /components/patchAccountDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogContent, 4 | DialogDescription, 5 | DialogHeader, 6 | DialogTitle, 7 | } from "@/components/ui/dialog" 8 | import { FormEvent, useEffect, useState } from "react"; 9 | import { patchAccount } from "@/app/dashboard/admin/actions"; 10 | import { LoaderCircle } from 'lucide-react'; 11 | 12 | export default function PatchAccountDialog({ id, emailDefault, open, setDialogOpen }: { id: string, emailDefault: string, open: boolean, setDialogOpen: (open: boolean) => void }) { 13 | const [email, setEmail] = useState(""); 14 | const [password, setPassword] = useState(null); 15 | const [error, setError] = useState(""); 16 | const [loading, setLoading] = useState(false); 17 | 18 | useEffect(() => { 19 | setEmail(emailDefault); 20 | }, [emailDefault]) 21 | 22 | async function editAccount(e: FormEvent) { 23 | e.preventDefault() 24 | if (!email) { 25 | return setError("Email tidak boleh kosong"); 26 | } 27 | 28 | if (!/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/g.test(email)) { 29 | return setError("Email tidak valid"); 30 | } 31 | 32 | setError(""); 33 | 34 | setLoading(true); 35 | 36 | const valid = await patchAccount(id, email, password) 37 | 38 | setLoading(false); 39 | 40 | if (!valid) { 41 | return setError("Ada masalah saat mengedit akun"); 42 | } 43 | 44 | setDialogOpen(false); 45 | setEmail(""); 46 | setPassword(""); 47 | window.location.reload(); 48 | } 49 | 50 | return ( 51 | 52 | 53 | 54 | Edit Akun 55 | 56 |
editAccount(e)} className="flex flex-col gap-y-6"> 57 | setEmail(e.target.value)} 62 | className="w-full px-4 py-2 rounded-lg border border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" 63 | /> 64 | setPassword(e.target.value)} 69 | className="w-full px-4 py-2 rounded-lg border border-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" 70 | /> 71 | { 72 | error &&

{error}

73 | } 74 |
75 | 81 | 90 |
91 |
92 |
93 |
94 |
95 |
96 | ) 97 | } -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | {/* 49 | Close */} 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "@/components/footer"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | 5 | export default function Home() { 6 | return ( 7 | <> 8 |
9 |
10 | Source Code Available On Github 11 |
12 | 13 |
14 |
15 |
16 |

Absensi Berbasis QR dengan autentikasi multilevel dan keamanan multilevel 🔥

17 |
18 | 19 | Login Admin 20 | 21 | 22 | Login Akun 23 | 24 |
25 |
26 | 27 | 28 |
29 |
30 | 31 |
32 |

Dibuat Dengan

33 |
34 | 35 | 36 | 37 | 38 |
39 |
40 | 41 |
42 |

Fitur Absenin

43 |

Apa Yang Membuat Absenin Berbeda?

44 | 45 |
46 |
47 |
48 |

1 Device 1 Kali Scan

49 |

Satu device hanya bisa 1x scan setiap QR, sehingga tidak bisa mengabsenkan orang lain.

50 |
51 |
52 |

Logging IP dan DeviceID Untuk Setiap Absen

53 |

Ketika orang lain absen, kita memastikan bahwa setiap absen itu UNIK, sehingga lebih aman.

54 |
55 |
56 |

Autentikasi Multilevel

57 |

Autentikasi Multilevel memastikan bahwa setiap user dapat memiliki anggota nya masing masing, sehingga lebih aman.

58 |
59 |
60 | 61 |
62 |
63 | 64 |
65 |

Cara Kerja

66 |

Bagaimana cara kerja pada Absenin?

67 | 68 | 69 |
70 | 71 |
72 |