diff --git a/qwik/github-oauth/.env.example b/qwik/github-oauth/.env.example new file mode 100644 index 0000000..0e0e5f9 --- /dev/null +++ b/qwik/github-oauth/.env.example @@ -0,0 +1,2 @@ +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" \ No newline at end of file diff --git a/qwik/github-oauth/.gitignore b/qwik/github-oauth/.gitignore new file mode 100644 index 0000000..48dce73 --- /dev/null +++ b/qwik/github-oauth/.gitignore @@ -0,0 +1,41 @@ +# Build +/dist +/lib +/lib-types +/server + +# Development +node_modules +*.local + +# Cache +.cache +.mf +.rollup.cache +tsconfig.tsbuildinfo + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Editor +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets + +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Yarn +.yarn/* +!.yarn/releases diff --git a/qwik/github-oauth/README.md b/qwik/github-oauth/README.md new file mode 100644 index 0000000..8f823ba --- /dev/null +++ b/qwik/github-oauth/README.md @@ -0,0 +1,26 @@ +# GitHub OAuth example with Lucia and Qwik + +This example uses `better-sqlite3`. + +```bash +# install dependencies +pnpm i + +# run dev server +pnpm dev +``` + +## Runtime + +This example is built for Node.js 20. If you're using Node.js 16/18, un-comment the following lines in `auth/lucia.ts`: + +```ts +// import "lucia/polyfill/node"; +``` + +## User schema + +| id | type | unique | +| ---------- | -------- | :----: | +| `id` | `string` | | +| `username` | `string` | ✓ | diff --git a/qwik/github-oauth/package.json b/qwik/github-oauth/package.json new file mode 100644 index 0000000..6353b3a --- /dev/null +++ b/qwik/github-oauth/package.json @@ -0,0 +1,40 @@ +{ + "name": "github-oauth", + "description": "GitHub OAuth example with Lucia and Qwik", + "engines": { + "node": ">=15.0.0" + }, + "scripts": { + "build": "qwik build", + "build.client": "vite build", + "build.preview": "vite build --ssr src/entry.preview.tsx", + "build.types": "tsc --incremental --noEmit", + "dev": "vite --mode ssr", + "dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force", + "lint": "eslint \"src/**/*.ts*\"", + "preview": "qwik build preview && vite preview --open", + "start": "vite --open --mode ssr", + "qwik": "qwik" + }, + "devDependencies": { + "@builder.io/qwik": "^1.2.10", + "@builder.io/qwik-city": "^1.2.10", + "@libsql/client": "^0.3.4", + "@lucia-auth/adapter-sqlite": "latest", + "@lucia-auth/oauth": "latest", + "@types/better-sqlite3": "^7.6.4", + "@types/eslint": "8.44.1", + "@types/node": "^20.4.5", + "@typescript-eslint/eslint-plugin": "6.2.0", + "@typescript-eslint/parser": "6.2.0", + "better-sqlite3": "^8.4.0", + "eslint": "8.45.0", + "eslint-plugin-qwik": "^1.2.10", + "lucia": "latest", + "prettier": "3.0.0", + "typescript": "5.1.6", + "undici": "5.22.1", + "vite": "4.4.7", + "vite-tsconfig-paths": "4.2.0" + } +} \ No newline at end of file diff --git a/qwik/github-oauth/public/favicon.svg b/qwik/github-oauth/public/favicon.svg new file mode 100644 index 0000000..0ded7c1 --- /dev/null +++ b/qwik/github-oauth/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qwik/github-oauth/public/manifest.json b/qwik/github-oauth/public/manifest.json new file mode 100644 index 0000000..c18e75f --- /dev/null +++ b/qwik/github-oauth/public/manifest.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/web-manifest-combined.json", + "name": "qwik-project-name", + "short_name": "Welcome to Qwik", + "start_url": ".", + "display": "standalone", + "background_color": "#fff", + "description": "A Qwik project app." +} diff --git a/qwik/github-oauth/public/robots.txt b/qwik/github-oauth/public/robots.txt new file mode 100644 index 0000000..e69de29 diff --git a/qwik/github-oauth/schema.sql b/qwik/github-oauth/schema.sql new file mode 100644 index 0000000..c62ad0c --- /dev/null +++ b/qwik/github-oauth/schema.sql @@ -0,0 +1,17 @@ +CREATE TABLE user ( + id VARCHAR(15) PRIMARY KEY, + username VARCHAR(31) NOT NULL UNIQUE +); +CREATE TABLE user_key ( + id VARCHAR(255) PRIMARY KEY, + user_id VARCHAR(15) NOT NULL, + hashed_password VARCHAR(255), + FOREIGN KEY (user_id) REFERENCES user(id) +); +CREATE TABLE user_session ( + id VARCHAR(127) PRIMARY KEY, + user_id VARCHAR(15) NOT NULL, + active_expires BIGINT NOT NULL, + idle_expires BIGINT NOT NULL, + FOREIGN KEY (user_id) REFERENCES user(id) +); \ No newline at end of file diff --git a/qwik/github-oauth/src/app.d.ts b/qwik/github-oauth/src/app.d.ts new file mode 100644 index 0000000..25b25b8 --- /dev/null +++ b/qwik/github-oauth/src/app.d.ts @@ -0,0 +1,8 @@ +/// +declare namespace Lucia { + type Auth = import("./auth/lucia").Auth; + type DatabaseUserAttributes = { + username: string; + }; + type DatabaseSessionAttributes = {}; +} \ No newline at end of file diff --git a/qwik/github-oauth/src/auth/lucia.ts b/qwik/github-oauth/src/auth/lucia.ts new file mode 100644 index 0000000..80b8186 --- /dev/null +++ b/qwik/github-oauth/src/auth/lucia.ts @@ -0,0 +1,35 @@ +import fs from "node:fs"; +import { betterSqlite3 } from "@lucia-auth/adapter-sqlite"; +import { github } from "@lucia-auth/oauth/providers"; +import sqlite from "better-sqlite3"; +import { lucia } from "lucia"; +import { qwik } from "lucia/middleware"; +// import "lucia/polyfill/node"; + +const db = sqlite(":memory:"); +db.exec(fs.readFileSync("schema.sql", "utf8")); + +export const auth = lucia({ + adapter: betterSqlite3(db, { + user: "user", + session: "user_session", + key: "user_key" + }), + env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", + middleware: qwik(), + sessionCookie: { + expires: false + }, + getUserAttributes: (data) => { + return { + username: data.username + }; + } +}); + +export const githubAuth = github(auth, { + clientId: process.env.GITHUB_CLIENT_ID ?? "", + clientSecret: process.env.GITHUB_CLIENT_SECRET ?? "" +}); + +export type Auth = typeof auth; diff --git a/qwik/github-oauth/src/entry.dev.tsx b/qwik/github-oauth/src/entry.dev.tsx new file mode 100644 index 0000000..3343dc4 --- /dev/null +++ b/qwik/github-oauth/src/entry.dev.tsx @@ -0,0 +1,18 @@ +/* + * WHAT IS THIS FILE? + * + * Development entry point using only client-side modules: + * - Do not use this mode in production! + * - No SSR + * - No portion of the application is pre-rendered on the server. + * - All of the application is running eagerly in the browser. + * - More code is transferred to the browser than in SSR mode. + * - Optimizer/Serialization/Deserialization code is not exercised! + */ +import { type RenderOptions, render } from "@builder.io/qwik"; + +import Root from "./root"; + +export default function (opts: RenderOptions) { + return render(document, , opts); +} diff --git a/qwik/github-oauth/src/entry.preview.tsx b/qwik/github-oauth/src/entry.preview.tsx new file mode 100644 index 0000000..44aa4e7 --- /dev/null +++ b/qwik/github-oauth/src/entry.preview.tsx @@ -0,0 +1,21 @@ +/* + * WHAT IS THIS FILE? + * + * It's the bundle entry point for `npm run preview`. + * That is, serving your app built in production mode. + * + * Feel free to modify this file, but don't remove it! + * + * Learn more about Vite's preview command: + * - https://vitejs.dev/config/preview-options.html#preview-options + * + */ +import { createQwikCity } from "@builder.io/qwik-city/middleware/node"; +import qwikCityPlan from "@qwik-city-plan"; + +import render from "./entry.ssr"; + +/** + * The default export is the QwikCity adapter used by Vite preview. + */ +export default createQwikCity({ render, qwikCityPlan }); diff --git a/qwik/github-oauth/src/entry.ssr.tsx b/qwik/github-oauth/src/entry.ssr.tsx new file mode 100644 index 0000000..836b078 --- /dev/null +++ b/qwik/github-oauth/src/entry.ssr.tsx @@ -0,0 +1,31 @@ +/** + * WHAT IS THIS FILE? + * + * SSR entry point, in all cases the application is rendered outside the browser, this + * entry point will be the common one. + * + * - Server (express, cloudflare...) + * - npm run start + * - npm run preview + * - npm run build + * + */ +import { + type RenderToStreamOptions, + renderToStream +} from "@builder.io/qwik/server"; +import { manifest } from "@qwik-client-manifest"; + +import Root from "./root"; + +export default function (opts: RenderToStreamOptions) { + return renderToStream(, { + manifest, + ...opts, + // Use container attributes to set attributes on the html tag. + containerAttributes: { + lang: "en-us", + ...opts.containerAttributes + } + }); +} diff --git a/qwik/github-oauth/src/global.css b/qwik/github-oauth/src/global.css new file mode 100644 index 0000000..f4a9ade --- /dev/null +++ b/qwik/github-oauth/src/global.css @@ -0,0 +1,42 @@ +/** + * WHAT IS THIS FILE? + * + * Globally applied styles. No matter which components are in the page or matching route, + * the styles in here will be applied to the Document, without any sort of CSS scoping. + * + */ +html { + -webkit-text-size-adjust: 100%; + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + "Noto Sans", + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; +} + +body { + padding: 2rem; + line-height: inherit; +} + +* { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.error { + color: red; +} diff --git a/qwik/github-oauth/src/root.tsx b/qwik/github-oauth/src/root.tsx new file mode 100644 index 0000000..61fba7a --- /dev/null +++ b/qwik/github-oauth/src/root.tsx @@ -0,0 +1,31 @@ +import { component$ } from "@builder.io/qwik"; +import { + QwikCityProvider, + RouterOutlet, + ServiceWorkerRegister +} from "@builder.io/qwik-city"; + +import "./global.css"; + +export default component$(() => { + /** + * The root of a QwikCity site always start with the component, + * immediately followed by the document's and . + * + * Don't remove the `` and `` elements. + */ + + return ( + + + + + + + + + + + + ); +}); diff --git a/qwik/github-oauth/src/routes/index.tsx b/qwik/github-oauth/src/routes/index.tsx new file mode 100644 index 0000000..de2b4f2 --- /dev/null +++ b/qwik/github-oauth/src/routes/index.tsx @@ -0,0 +1,52 @@ +import { component$ } from "@builder.io/qwik"; +import { + type DocumentHead, + Form, + routeAction$, + routeLoader$ +} from "@builder.io/qwik-city"; + +import { auth } from "~/auth/lucia"; + +export const useSessionUser = routeLoader$(async (event) => { + const authRequest = auth.handleRequest(event); + const session = await authRequest.validate(); + if (!session) throw event.redirect(302, "/signin/"); + return session.user; +}); + +export const useSignOutAction = routeAction$(async (_, event) => { + const authRequest = auth.handleRequest(event); + const session = await authRequest.validate(); + if (!session) throw event.error(401, "Unauthorized"); + + await auth.invalidateSession(session.sessionId); + const { name, value, attributes } = auth.createSessionCookie(null); + event.cookie.set(name, value, attributes); + throw event.redirect(302, "/login/"); +}); + +export default component$(() => { + const user = useSessionUser(); + const signout = useSignOutAction(); + return ( + <> +

Profile

+

User id: {user.value.userId}

+

GitHub username: {user.value.username}

+
+ +
+ + ); +}); + +export const head: DocumentHead = { + title: "GitHub OAuth with Lucia", + meta: [ + { + name: "description", + content: "Qwik site description" + } + ] +}; diff --git a/qwik/github-oauth/src/routes/layout.tsx b/qwik/github-oauth/src/routes/layout.tsx new file mode 100644 index 0000000..f9bdd58 --- /dev/null +++ b/qwik/github-oauth/src/routes/layout.tsx @@ -0,0 +1,17 @@ +import { Slot, component$ } from "@builder.io/qwik"; +import type { RequestHandler } from "@builder.io/qwik-city"; + +export const onGet: RequestHandler = async ({ cacheControl }) => { + // Control caching for this request for best performance and to reduce hosting costs: + // https://qwik.builder.io/docs/caching/ + cacheControl({ + // Always serve a cached response by default, up to a week stale + staleWhileRevalidate: 60 * 60 * 24 * 7, + // Max once every 5 seconds, revalidate on the server to get a fresh version of this page + maxAge: 5 + }); +}; + +export default component$(() => { + return ; +}); diff --git a/qwik/github-oauth/src/routes/service-worker.ts b/qwik/github-oauth/src/routes/service-worker.ts new file mode 100644 index 0000000..8ae1dc9 --- /dev/null +++ b/qwik/github-oauth/src/routes/service-worker.ts @@ -0,0 +1,18 @@ +/* + * WHAT IS THIS FILE? + * + * The service-worker.ts file is used to have state of the art prefetching. + * https://qwik.builder.io/qwikcity/prefetching/overview/ + * + * Qwik uses a service worker to speed up your site and reduce latency, ie, not used in the traditional way of offline. + * You can also use this file to add more functionality that runs in the service worker. + */ +import { setupServiceWorker } from "@builder.io/qwik-city/service-worker"; + +setupServiceWorker(); + +declare const self: ServiceWorkerGlobalScope; + +addEventListener("install", () => self.skipWaiting()); + +addEventListener("activate", () => self.clients.claim()); diff --git a/qwik/github-oauth/src/routes/signin/github/callback/index.ts b/qwik/github-oauth/src/routes/signin/github/callback/index.ts new file mode 100644 index 0000000..a8299ab --- /dev/null +++ b/qwik/github-oauth/src/routes/signin/github/callback/index.ts @@ -0,0 +1,38 @@ +import type { RequestHandler } from "@builder.io/qwik-city"; + +import { auth, githubAuth } from "~/auth/lucia"; + +export const onRequest: RequestHandler = async (event) => { + const authRequest = auth.handleRequest(event); + const session = await authRequest.validate(); + if (session) throw event.redirect(302, "/"); + const storedState = event.cookie.get("github_oauth_state")?.value; + const state = event.query.get("state"); + const code = event.query.get("code"); + if (!storedState || !state || storedState !== state || !code) { + throw event.error(400, ""); + } + + const { getExistingUser, githubUser, createUser } = + await githubAuth.validateCallback(code); + + const getUser = async () => { + const existingUser = await getExistingUser(); + if (existingUser) return existingUser; + const user = await createUser({ + attributes: { + username: githubUser.login + } + }); + return user; + }; + + const user = await getUser(); + const newSession = await auth.createSession({ + userId: user.userId, + attributes: {} + }); + const { name, value, attributes } = auth.createSessionCookie(newSession); + event.cookie.set(name, value, attributes); + throw event.redirect(302, "/"); +}; diff --git a/qwik/github-oauth/src/routes/signin/github/index.ts b/qwik/github-oauth/src/routes/signin/github/index.ts new file mode 100644 index 0000000..fd36709 --- /dev/null +++ b/qwik/github-oauth/src/routes/signin/github/index.ts @@ -0,0 +1,14 @@ +import type { RequestHandler } from "@builder.io/qwik-city"; + +import { githubAuth } from "~/auth/lucia"; + +export const onGet: RequestHandler = async (event) => { + const [url, state] = await githubAuth.getAuthorizationUrl(); + event.cookie.set("github_oauth_state", state, { + httpOnly: true, + secure: false, + path: "/", + maxAge: 60 * 60 + }); + throw event.redirect(302, url.toString()); +}; diff --git a/qwik/github-oauth/src/routes/signin/index.tsx b/qwik/github-oauth/src/routes/signin/index.tsx new file mode 100644 index 0000000..1aa4030 --- /dev/null +++ b/qwik/github-oauth/src/routes/signin/index.tsx @@ -0,0 +1,10 @@ +import { component$ } from "@builder.io/qwik"; + +export default component$(() => { + return ( + <> +

Sign in

+ Sign in with GitHub + + ); +}); diff --git a/qwik/github-oauth/tsconfig.json b/qwik/github-oauth/tsconfig.json new file mode 100644 index 0000000..6fb74a8 --- /dev/null +++ b/qwik/github-oauth/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "allowJs": true, + "target": "ES2017", + "module": "ES2022", + "lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "@builder.io/qwik", + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "incremental": true, + "isolatedModules": true, + "outDir": "tmp", + "noEmit": true, + "types": ["node", "vite/client"], + "paths": { + "~/*": ["./src/*"] + } + }, + "include": ["src", "./*.d.ts", "./*.config.ts"] +} diff --git a/qwik/github-oauth/vite.config.ts b/qwik/github-oauth/vite.config.ts new file mode 100644 index 0000000..5c11249 --- /dev/null +++ b/qwik/github-oauth/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import { qwikVite } from "@builder.io/qwik/optimizer"; +import { qwikCity } from "@builder.io/qwik-city/vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig(() => { + return { + plugins: [qwikCity(), qwikVite(), tsconfigPaths()], + preview: { + headers: { + "Cache-Control": "public, max-age=600", + }, + }, + }; +}); diff --git a/qwik/username-and-password/.env.example b/qwik/username-and-password/.env.example new file mode 100644 index 0000000..0e0e5f9 --- /dev/null +++ b/qwik/username-and-password/.env.example @@ -0,0 +1,2 @@ +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" \ No newline at end of file diff --git a/qwik/username-and-password/.gitignore b/qwik/username-and-password/.gitignore new file mode 100644 index 0000000..48dce73 --- /dev/null +++ b/qwik/username-and-password/.gitignore @@ -0,0 +1,41 @@ +# Build +/dist +/lib +/lib-types +/server + +# Development +node_modules +*.local + +# Cache +.cache +.mf +.rollup.cache +tsconfig.tsbuildinfo + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Editor +.vscode/* +!.vscode/launch.json +!.vscode/*.code-snippets + +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Yarn +.yarn/* +!.yarn/releases diff --git a/qwik/username-and-password/README.md b/qwik/username-and-password/README.md new file mode 100644 index 0000000..5ea2da3 --- /dev/null +++ b/qwik/username-and-password/README.md @@ -0,0 +1,26 @@ +# Username & password example with Lucia and Qwik + +This example uses `better-sqlite3`. + +```bash +# install dependencies +pnpm i + +# run dev server +pnpm dev +``` + +## Runtime + +This example is built for Node.js 20. If you're using Node.js 16/18, un-comment the following lines in `auth/lucia.ts`: + +```ts +// import "lucia/polyfill/node"; +``` + +## User schema + +| id | type | unique | +| ---------- | -------- | :----: | +| `id` | `string` | | +| `username` | `string` | ✓ | diff --git a/qwik/username-and-password/package.json b/qwik/username-and-password/package.json new file mode 100644 index 0000000..b824b08 --- /dev/null +++ b/qwik/username-and-password/package.json @@ -0,0 +1,39 @@ +{ + "name": "username-and-password", + "description": "Username & password example with Lucia and Qwik", + "engines": { + "node": ">=15.0.0" + }, + "scripts": { + "build": "qwik build", + "build.client": "vite build", + "build.preview": "vite build --ssr src/entry.preview.tsx", + "build.types": "tsc --incremental --noEmit", + "dev": "vite --mode ssr", + "dev.debug": "node --inspect-brk ./node_modules/vite/bin/vite.js --mode ssr --force", + "lint": "eslint \"src/**/*.ts*\"", + "preview": "qwik build preview && vite preview --open", + "start": "vite --open --mode ssr", + "qwik": "qwik" + }, + "devDependencies": { + "@builder.io/qwik": "^1.2.10", + "@builder.io/qwik-city": "^1.2.10", + "@libsql/client": "^0.3.4", + "@lucia-auth/adapter-sqlite": "latest", + "@types/better-sqlite3": "^7.6.4", + "@types/eslint": "8.44.1", + "@types/node": "^20.4.5", + "@typescript-eslint/eslint-plugin": "6.2.0", + "@typescript-eslint/parser": "6.2.0", + "better-sqlite3": "^8.4.0", + "eslint": "8.45.0", + "eslint-plugin-qwik": "^1.2.10", + "lucia": "latest", + "prettier": "3.0.0", + "typescript": "5.1.6", + "undici": "5.22.1", + "vite": "4.4.7", + "vite-tsconfig-paths": "4.2.0" + } +} \ No newline at end of file diff --git a/qwik/username-and-password/public/favicon.svg b/qwik/username-and-password/public/favicon.svg new file mode 100644 index 0000000..0ded7c1 --- /dev/null +++ b/qwik/username-and-password/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qwik/username-and-password/public/manifest.json b/qwik/username-and-password/public/manifest.json new file mode 100644 index 0000000..c18e75f --- /dev/null +++ b/qwik/username-and-password/public/manifest.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://json.schemastore.org/web-manifest-combined.json", + "name": "qwik-project-name", + "short_name": "Welcome to Qwik", + "start_url": ".", + "display": "standalone", + "background_color": "#fff", + "description": "A Qwik project app." +} diff --git a/qwik/username-and-password/public/robots.txt b/qwik/username-and-password/public/robots.txt new file mode 100644 index 0000000..e69de29 diff --git a/qwik/username-and-password/schema.sql b/qwik/username-and-password/schema.sql new file mode 100644 index 0000000..c62ad0c --- /dev/null +++ b/qwik/username-and-password/schema.sql @@ -0,0 +1,17 @@ +CREATE TABLE user ( + id VARCHAR(15) PRIMARY KEY, + username VARCHAR(31) NOT NULL UNIQUE +); +CREATE TABLE user_key ( + id VARCHAR(255) PRIMARY KEY, + user_id VARCHAR(15) NOT NULL, + hashed_password VARCHAR(255), + FOREIGN KEY (user_id) REFERENCES user(id) +); +CREATE TABLE user_session ( + id VARCHAR(127) PRIMARY KEY, + user_id VARCHAR(15) NOT NULL, + active_expires BIGINT NOT NULL, + idle_expires BIGINT NOT NULL, + FOREIGN KEY (user_id) REFERENCES user(id) +); \ No newline at end of file diff --git a/qwik/username-and-password/src/app.d.ts b/qwik/username-and-password/src/app.d.ts new file mode 100644 index 0000000..8a2c49f --- /dev/null +++ b/qwik/username-and-password/src/app.d.ts @@ -0,0 +1,8 @@ +/// +declare namespace Lucia { + type Auth = import("./auth/lucia").Auth; + type DatabaseUserAttributes = { + username: string; + }; + type DatabaseSessionAttributes = {}; +} diff --git a/qwik/username-and-password/src/auth/lucia.ts b/qwik/username-and-password/src/auth/lucia.ts new file mode 100644 index 0000000..bdd0fba --- /dev/null +++ b/qwik/username-and-password/src/auth/lucia.ts @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import { betterSqlite3 } from "@lucia-auth/adapter-sqlite"; +import sqlite from "better-sqlite3"; +import { lucia } from "lucia"; +import { qwik } from "lucia/middleware"; +// import "lucia/polyfill/node"; + +const db = sqlite(":memory:"); +db.exec(fs.readFileSync("schema.sql", "utf8")); + +export const auth = lucia({ + adapter: betterSqlite3(db, { + user: "user", + session: "user_session", + key: "user_key" + }), + env: process.env.NODE_ENV === "development" ? "DEV" : "PROD", + middleware: qwik(), + sessionCookie: { + expires: false + }, + getUserAttributes: (data) => { + return { + username: data.username + }; + } +}); + +export type Auth = typeof auth; diff --git a/qwik/username-and-password/src/entry.dev.tsx b/qwik/username-and-password/src/entry.dev.tsx new file mode 100644 index 0000000..3343dc4 --- /dev/null +++ b/qwik/username-and-password/src/entry.dev.tsx @@ -0,0 +1,18 @@ +/* + * WHAT IS THIS FILE? + * + * Development entry point using only client-side modules: + * - Do not use this mode in production! + * - No SSR + * - No portion of the application is pre-rendered on the server. + * - All of the application is running eagerly in the browser. + * - More code is transferred to the browser than in SSR mode. + * - Optimizer/Serialization/Deserialization code is not exercised! + */ +import { type RenderOptions, render } from "@builder.io/qwik"; + +import Root from "./root"; + +export default function (opts: RenderOptions) { + return render(document, , opts); +} diff --git a/qwik/username-and-password/src/entry.preview.tsx b/qwik/username-and-password/src/entry.preview.tsx new file mode 100644 index 0000000..44aa4e7 --- /dev/null +++ b/qwik/username-and-password/src/entry.preview.tsx @@ -0,0 +1,21 @@ +/* + * WHAT IS THIS FILE? + * + * It's the bundle entry point for `npm run preview`. + * That is, serving your app built in production mode. + * + * Feel free to modify this file, but don't remove it! + * + * Learn more about Vite's preview command: + * - https://vitejs.dev/config/preview-options.html#preview-options + * + */ +import { createQwikCity } from "@builder.io/qwik-city/middleware/node"; +import qwikCityPlan from "@qwik-city-plan"; + +import render from "./entry.ssr"; + +/** + * The default export is the QwikCity adapter used by Vite preview. + */ +export default createQwikCity({ render, qwikCityPlan }); diff --git a/qwik/username-and-password/src/entry.ssr.tsx b/qwik/username-and-password/src/entry.ssr.tsx new file mode 100644 index 0000000..836b078 --- /dev/null +++ b/qwik/username-and-password/src/entry.ssr.tsx @@ -0,0 +1,31 @@ +/** + * WHAT IS THIS FILE? + * + * SSR entry point, in all cases the application is rendered outside the browser, this + * entry point will be the common one. + * + * - Server (express, cloudflare...) + * - npm run start + * - npm run preview + * - npm run build + * + */ +import { + type RenderToStreamOptions, + renderToStream +} from "@builder.io/qwik/server"; +import { manifest } from "@qwik-client-manifest"; + +import Root from "./root"; + +export default function (opts: RenderToStreamOptions) { + return renderToStream(, { + manifest, + ...opts, + // Use container attributes to set attributes on the html tag. + containerAttributes: { + lang: "en-us", + ...opts.containerAttributes + } + }); +} diff --git a/qwik/username-and-password/src/global.css b/qwik/username-and-password/src/global.css new file mode 100644 index 0000000..f4a9ade --- /dev/null +++ b/qwik/username-and-password/src/global.css @@ -0,0 +1,42 @@ +/** + * WHAT IS THIS FILE? + * + * Globally applied styles. No matter which components are in the page or matching route, + * the styles in here will be applied to the Document, without any sort of CSS scoping. + * + */ +html { + -webkit-text-size-adjust: 100%; + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + "Helvetica Neue", + Arial, + "Noto Sans", + sans-serif, + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji"; +} + +body { + padding: 2rem; + line-height: inherit; +} + +* { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.error { + color: red; +} diff --git a/qwik/username-and-password/src/root.tsx b/qwik/username-and-password/src/root.tsx new file mode 100644 index 0000000..61fba7a --- /dev/null +++ b/qwik/username-and-password/src/root.tsx @@ -0,0 +1,31 @@ +import { component$ } from "@builder.io/qwik"; +import { + QwikCityProvider, + RouterOutlet, + ServiceWorkerRegister +} from "@builder.io/qwik-city"; + +import "./global.css"; + +export default component$(() => { + /** + * The root of a QwikCity site always start with the component, + * immediately followed by the document's and . + * + * Don't remove the `` and `` elements. + */ + + return ( + + + + + + + + + + + + ); +}); diff --git a/qwik/username-and-password/src/routes/(auth)/layout.tsx b/qwik/username-and-password/src/routes/(auth)/layout.tsx new file mode 100644 index 0000000..ebd15c2 --- /dev/null +++ b/qwik/username-and-password/src/routes/(auth)/layout.tsx @@ -0,0 +1,9 @@ +import type { RequestHandler } from "@builder.io/qwik-city"; + +import { auth } from "~/auth/lucia"; + +export const onGet: RequestHandler = async (event) => { + const authRequest = auth.handleRequest(event); + const session = await authRequest.validate(); + if (session) throw event.redirect(302, "/"); +}; diff --git a/qwik/username-and-password/src/routes/(auth)/signin/index.tsx b/qwik/username-and-password/src/routes/(auth)/signin/index.tsx new file mode 100644 index 0000000..1326e6b --- /dev/null +++ b/qwik/username-and-password/src/routes/(auth)/signin/index.tsx @@ -0,0 +1,82 @@ +import { component$ } from "@builder.io/qwik"; +import { Form, Link, routeAction$, z, zod$ } from "@builder.io/qwik-city"; +import { LuciaError } from "lucia"; + +import { auth } from "~/auth/lucia"; + +export const useSigninAction = routeAction$( + async (data, event) => { + const { username, password } = data; + try { + const key = await auth.useKey( + "username", + username.toLowerCase(), + password + ); + const session = await auth.createSession({ + userId: key.userId, + attributes: {} + }); + const { name, value, attributes } = auth.createSessionCookie(session); + event.cookie.set(name, value, attributes); + event.redirect(302, "/"); + } catch (e) { + if ( + e instanceof LuciaError && + (e.message === "AUTH_INVALID_KEY_ID" || + e.message === "AUTH_INVALID_PASSWORD") + ) { + return { + success: false, + error: "Incorrect username or password" + }; + } + } + }, + zod$({ + username: z.string().min(1).max(31), + password: z.string() + }) +); + +export default component$(() => { + const signinAction = useSigninAction(); + return ( + <> +

Sign in

+
+ + +
+ + +
+ +
+ {signinAction.value?.fieldErrors?.username?.length && ( + <> +

Username Issue(s):

+ {signinAction.value.fieldErrors.username.map((error, index) => ( +

+ {error} +

+ ))} + + )} + {signinAction.value?.fieldErrors?.password?.length && ( + <> +

Password Issue(s):

+ {signinAction.value.fieldErrors.password.map((error, index) => ( +

+ {error} +

+ ))} + + )} + {signinAction.value?.error && ( +

Invalid Username or Password

+ )} + Create an account + + ); +}); diff --git a/qwik/username-and-password/src/routes/(auth)/signup/index.tsx b/qwik/username-and-password/src/routes/(auth)/signup/index.tsx new file mode 100644 index 0000000..b650f9a --- /dev/null +++ b/qwik/username-and-password/src/routes/(auth)/signup/index.tsx @@ -0,0 +1,99 @@ +import { component$ } from "@builder.io/qwik"; +import { Form, Link, routeAction$, zod$ } from "@builder.io/qwik-city"; +import { SqliteError } from "better-sqlite3"; + +import { auth } from "~/auth/lucia"; + +export const useSignUpAction = routeAction$( + async (data, event) => { + try { + const { username, password } = data; + const user = await auth.createUser({ + key: { + providerId: "username", + providerUserId: username.toLowerCase(), + password + }, + attributes: { + username + } + }); + const session = await auth.createSession({ + userId: user.userId, + attributes: {} + }); + const authRequest = auth.handleRequest(event); + authRequest.setSession(session); + throw event.redirect(302, "/"); + } catch (e) { + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + return { + success: false, + error: "Unable to create user" + }; + } + } + }, + zod$((z) => { + return z + .object({ + username: z.string().min(4).max(31), + password: z.string().min(6).max(255), + confirmPassword: z.string().optional() + }) + .superRefine(({ password, confirmPassword }, ctx) => { + if (confirmPassword !== password) { + ctx.addIssue({ + code: "custom", + message: "The passwords did not match", + path: ["password"] + }); + } + }); + }) +); + +export default component$(() => { + const signUpAction = useSignUpAction(); + return ( + <> +

Sign up

+
+ + +
+ + +
+ + +
+ +
+ {signUpAction.value?.fieldErrors?.username?.length && ( + <> +

Username Issue(s):

+ {signUpAction.value.fieldErrors.username.map((error, index) => ( +

+ {error} +

+ ))} + + )} + {signUpAction.value?.fieldErrors?.password?.length && ( + <> +

Password Issue(s):

+ {signUpAction.value.fieldErrors.password.map((error, index) => ( +

+ {error} +

+ ))} + + )} + {signUpAction.value?.error && ( +

{signUpAction.value.error}

+ )} + Sign in + + ); +}); diff --git a/qwik/username-and-password/src/routes/index.tsx b/qwik/username-and-password/src/routes/index.tsx new file mode 100644 index 0000000..f54b7bf --- /dev/null +++ b/qwik/username-and-password/src/routes/index.tsx @@ -0,0 +1,51 @@ +import { component$ } from "@builder.io/qwik"; +import { + type DocumentHead, + Form, + routeAction$, + routeLoader$ +} from "@builder.io/qwik-city"; + +import { auth } from "~/auth/lucia"; + +export const useSessionUser = routeLoader$(async (event) => { + const authRequest = auth.handleRequest(event); + const session = await authRequest.validate(); + if (!session) throw event.redirect(302, "/signin/"); + return session.user; +}); + +export const useSignOutAction = routeAction$(async (_, event) => { + const authRequest = auth.handleRequest(event); + const session = await authRequest.validate(); + if (!session) throw event.error(401, "Unauthorized"); + + await auth.invalidateSession(session.sessionId); + authRequest.setSession(null); + throw event.redirect(302, "/login/"); +}); + +export default component$(() => { + const user = useSessionUser(); + const signout = useSignOutAction(); + return ( + <> +

Profile

+

User id: {user.value.userId}

+

Username: {user.value.username}

+
+ +
+ + ); +}); + +export const head: DocumentHead = { + title: "GitHub OAuth with Lucia", + meta: [ + { + name: "description", + content: "Qwik site description" + } + ] +}; diff --git a/qwik/username-and-password/src/routes/layout.tsx b/qwik/username-and-password/src/routes/layout.tsx new file mode 100644 index 0000000..f9bdd58 --- /dev/null +++ b/qwik/username-and-password/src/routes/layout.tsx @@ -0,0 +1,17 @@ +import { Slot, component$ } from "@builder.io/qwik"; +import type { RequestHandler } from "@builder.io/qwik-city"; + +export const onGet: RequestHandler = async ({ cacheControl }) => { + // Control caching for this request for best performance and to reduce hosting costs: + // https://qwik.builder.io/docs/caching/ + cacheControl({ + // Always serve a cached response by default, up to a week stale + staleWhileRevalidate: 60 * 60 * 24 * 7, + // Max once every 5 seconds, revalidate on the server to get a fresh version of this page + maxAge: 5 + }); +}; + +export default component$(() => { + return ; +}); diff --git a/qwik/username-and-password/src/routes/service-worker.ts b/qwik/username-and-password/src/routes/service-worker.ts new file mode 100644 index 0000000..8ae1dc9 --- /dev/null +++ b/qwik/username-and-password/src/routes/service-worker.ts @@ -0,0 +1,18 @@ +/* + * WHAT IS THIS FILE? + * + * The service-worker.ts file is used to have state of the art prefetching. + * https://qwik.builder.io/qwikcity/prefetching/overview/ + * + * Qwik uses a service worker to speed up your site and reduce latency, ie, not used in the traditional way of offline. + * You can also use this file to add more functionality that runs in the service worker. + */ +import { setupServiceWorker } from "@builder.io/qwik-city/service-worker"; + +setupServiceWorker(); + +declare const self: ServiceWorkerGlobalScope; + +addEventListener("install", () => self.skipWaiting()); + +addEventListener("activate", () => self.clients.claim()); diff --git a/qwik/username-and-password/tsconfig.json b/qwik/username-and-password/tsconfig.json new file mode 100644 index 0000000..6fb74a8 --- /dev/null +++ b/qwik/username-and-password/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "allowJs": true, + "target": "ES2017", + "module": "ES2022", + "lib": ["es2022", "DOM", "WebWorker", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "@builder.io/qwik", + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "incremental": true, + "isolatedModules": true, + "outDir": "tmp", + "noEmit": true, + "types": ["node", "vite/client"], + "paths": { + "~/*": ["./src/*"] + } + }, + "include": ["src", "./*.d.ts", "./*.config.ts"] +} diff --git a/qwik/username-and-password/vite.config.ts b/qwik/username-and-password/vite.config.ts new file mode 100644 index 0000000..5c11249 --- /dev/null +++ b/qwik/username-and-password/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import { qwikVite } from "@builder.io/qwik/optimizer"; +import { qwikCity } from "@builder.io/qwik-city/vite"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig(() => { + return { + plugins: [qwikCity(), qwikVite(), tsconfigPaths()], + preview: { + headers: { + "Cache-Control": "public, max-age=600", + }, + }, + }; +});