Vuetify login
Awesome — here’s a clean, working pattern to add username/password login → JWT → protected CRUD to the Vue + Vuetify + vue-postgrest app.
It includes:
SQL to create a users table, bcrypt hashing, and an api.login RPC that returns a JWT
A Vue auth store (tiny, no Pinia needed) that persists the token
A router guard that sends unauth’d users to /login
A Login.vue page that POSTs to /rpc/login, saves the token, and redirects
Minor changes to the TodosCrud.vue calls so every request carries Authorization: Bearer
1) Postgres / PostgREST: users + login RPC
-- Enable needed extensions CREATE EXTENSION IF NOT EXISTS pgcrypto; -- for crypt() / gen_salt() CREATE EXTENSION IF NOT EXISTS pgjwt; -- for sign()
-- Users table (bcrypt hashed password) CREATE TABLE public.users (
id serial PRIMARY KEY, username text UNIQUE NOT NULL, pass_hash text NOT NULL, -- bcrypt role text NOT NULL DEFAULT 'anon', -- app role in the JWT (e.g., 'web', 'anon') created_at timestamptz NOT NULL DEFAULT now()
);
-- Demo user: username 'demo', password 'demo123' INSERT INTO public.users (username, pass_hash, role) VALUES (
'demo', crypt('demo123', gen_salt('bf')), 'web' -- whatever role your PostgREST uses for authenticated requests
);
-- Example todos table (CRUD role grants) CREATE TABLE public.todos(
id serial PRIMARY KEY, title text NOT NULL, done boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now()
);
-- Grant access to web role (and maybe read to anon if you want)
GRANT SELECT, INSERT, UPDATE, DELETE ON public.todos TO web;
-- If your PostgREST anon role is anon
, keep this locked down for anon:
REVOKE ALL ON public.todos FROM anon;
-- JWT login RPC (returns { token:
_user public.users%ROWTYPE; _claims json; _secret text := current_setting('app.jwt_secret', true); -- we'll set this GUC in postgrest
BEGIN
SELECT * INTO _user FROM public.users WHERE users.username = login.username; IF NOT FOUND THEN RAISE EXCEPTION 'invalid_user_or_password' USING ERRCODE = '28P01'; END IF;
IF crypt(password, _user.pass_hash) <> _user.pass_hash THEN
RAISE EXCEPTION 'invalid_user_or_password' USING ERRCODE = '28P01'; END IF;
-- JWT claims — include PostgREST-required 'role'
_claims := json_build_object( 'sub', _user.id, 'role', _user.role, 'username', _user.username, 'iat', extract(epoch from now())::int, 'exp', (extract(epoch from now())::int + 60*60*8) -- 8h );
IF coalesce(_secret,) = THEN
RAISE EXCEPTION 'jwt_secret_not_configured'; END IF;
token := sign(_claims, _secret);
RETURN NEXT;
END; $$;
-- PostgREST config notes (postgrest.conf):
-- db-anon-role = "anon"
-- db-pre-request = "app.settings" (optional)
-- app.settings.jwt_secret = "
-- Finally, expose RPC to anon so they can log in GRANT EXECUTE ON FUNCTION api.login(text, text) TO anon;
Make sure PostgREST uses the same secret as the function (app.settings.jwt_secret or PGRST_JWT_SECRET). The function reads it via current_setting('app.jwt_secret', true).
2) Tiny auth helper (token store)
// src/auth.ts import { ref, computed } from 'vue'
const LS_KEY = 'jwt'
const _token = ref
export const isAuthed = computed(() => !!_token.value) export const token = computed({
get: () => _token.value, set: (t: string | null) => { _token.value = t if (t) localStorage.setItem(LS_KEY, t) else localStorage.removeItem(LS_KEY) }
})
export function authHeaders() {
return token.value ? { Authorization: Bearer ${token.value}
} : {}
}
3) Router with auth guard
// src/router.ts import { createRouter, createWebHistory } from 'vue-router' import { isAuthed } from './auth' import TodosCrud from './components/TodosCrud.vue' import Login from './views/Login.vue'
const routes = [
{ path: '/login', name: 'login', component: Login, meta: { public: true } }, { path: '/', name: 'home', component: TodosCrud }, // protected
]
export const router = createRouter({
history: createWebHistory(), routes,
})
router.beforeEach((to) => {
if (to.meta.public) return true if (!isAuthed.value) return { name: 'login', query: { redirect: to.fullPath } } return true
})
4) main.ts (Vuetify + vue-postgrest + Router)
// src/main.ts import { createApp } from 'vue' import App from './App.vue'
import 'vuetify/styles' import { createVuetify } from 'vuetify' import { aliases, mdi } from 'vuetify/iconsets/mdi' import '@mdi/font/css/materialdesignicons.css' const vuetify = createVuetify({ icons: { defaultSet: 'mdi', aliases, sets: { mdi } } })
import VuePostgrest from 'vue-postgrest' import { router } from './router'
const app = createApp(App)
app.use(VuePostgrest, {
apiRoot: 'http://localhost:3000/', // your PostgREST base URL // We’ll pass per-request headers from components so we can update after login.
})
app.use(vuetify) app.use(router) app.mount('#app')
5) Login page (POST /rpc/login, save token, redirect)
6) Add a Logout button (optional)
In App.vue (or a small TopBar), add:
7) Update TodosCrud.vue to send Authorization on each request
We’ll reuse the same component you already had, but add headers: authHeaders() wherever we call PostgREST:
This keeps it explicit and guarantees every request carries the latest token. If vue-postgrest in your version supports a global headers setter, you can centralize this, but the above is bulletproof.
That’s it
Go to /login, sign in as demo / demo123
You’ll be redirected to /, where all todos requests now include your JWT
Use Add / Edit / Delete / Toggle Done; all calls are authorized
If you want, I can also wire up server-side pagination in the Vuetify v-data-table that drives limit/offset in pg.$get({ range: { limit, offset }}) and uses pg.$range.totalCount for the total row count.