implement login with vue

* use vue router
* use pinia
* use tailwindcss
* use vite
* transform /api/login and /api/login/callback into JSON APIs
* add Access-Control-Allow-Credentials header
* add TODO about JSON errors
This commit is contained in:
ekzyis 2023-11-04 14:31:48 +01:00
parent d1b7786434
commit 4e343d49d0
22 changed files with 605 additions and 5164 deletions

View File

@ -30,7 +30,7 @@ func NewLNAuth() (*LNAuth, error) {
return nil, fmt.Errorf("rand.Read error: %w", err) return nil, fmt.Errorf("rand.Read error: %w", err)
} }
k1hex := hex.EncodeToString(k1) k1hex := hex.EncodeToString(k1)
url := []byte(fmt.Sprintf("https://%s/api/login?tag=login&k1=%s&action=login", env.PublicURL, k1hex)) url := []byte(fmt.Sprintf("https://%s/api/login/callback?tag=login&k1=%s&action=login", env.PublicURL, k1hex))
conv, err := bech32.ConvertBits(url, 8, 5, true) conv, err := bech32.ConvertBits(url, 8, 5, true)
if err != nil { if err != nil {
return nil, fmt.Errorf("bech32.ConvertBits error: %w", err) return nil, fmt.Errorf("bech32.ConvertBits error: %w", err)

View File

@ -37,6 +37,7 @@ func serveError(c echo.Context, code int) error {
c.Logger().Error(err) c.Logger().Error(err)
return err return err
} }
// TODO return errors in JSON
if err = c.Stream(code, "text/html", f); err != nil { if err = c.Stream(code, "text/html", f); err != nil {
c.Logger().Error(err) c.Logger().Error(err)
return err return err

View File

@ -34,11 +34,10 @@ func HandleLogin(sc context.ServerContext) echo.HandlerFunc {
return err return err
} }
data = map[string]any{ data = map[string]any{
"session": c.Get("session"), "lnurl": lnAuth.LNURL,
"lnurl": lnAuth.LNURL, "qr": qr,
"qr": qr,
} }
return sc.Render(c, http.StatusOK, "login.html", data) return c.JSON(http.StatusOK, data)
} }
} }

View File

@ -17,13 +17,16 @@ func HandleCheckSession(sc context.ServerContext) echo.HandlerFunc {
err error err error
) )
if cookie, err = c.Cookie("session"); err != nil { if cookie, err = c.Cookie("session"); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, map[string]string{"reason": "cookie required"}) // return echo.NewHTTPError(http.StatusBadRequest, map[string]string{"reason": "cookie required"})
return c.JSON(http.StatusBadRequest, map[string]string{"reason": "cookie required"})
} }
s = db.Session{SessionId: cookie.Value} s = db.Session{SessionId: cookie.Value}
if err = sc.Db.FetchSession(&s); err == sql.ErrNoRows { if err = sc.Db.FetchSession(&s); err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, map[string]string{"reason": "session not found"}) // return echo.NewHTTPError(http.StatusNotFound, map[string]string{"reason": "session not found"})
return c.JSON(http.StatusBadRequest, map[string]string{"reason": "session not found"})
} else if err != nil { } else if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError) // return echo.NewHTTPError(http.StatusInternalServerError)
return c.JSON(http.StatusInternalServerError, nil)
} }
return c.JSON(http.StatusOK, map[string]string{"pubkey": s.Pubkey}) return c.JSON(http.StatusOK, map[string]string{"pubkey": s.Pubkey})
} }

View File

@ -25,7 +25,6 @@ func mountMiddleware(e *echo.Echo, sc ServerContext) {
func addFrontendRoutes(e *echo.Echo, sc ServerContext) { func addFrontendRoutes(e *echo.Echo, sc ServerContext) {
GET(e, sc, "/", handler.HandleIndex) GET(e, sc, "/", handler.HandleIndex)
GET(e, sc, "/login", handler.HandleLogin)
POST(e, sc, "/logout", handler.HandleLogout) POST(e, sc, "/logout", handler.HandleLogout)
GET(e, sc, "/user", GET(e, sc, "/user",
handler.HandleUser, handler.HandleUser,
@ -43,7 +42,8 @@ func addFrontendRoutes(e *echo.Echo, sc ServerContext) {
} }
func addBackendRoutes(e *echo.Echo, sc ServerContext) { func addBackendRoutes(e *echo.Echo, sc ServerContext) {
GET(e, sc, "/api/login", handler.HandleLoginCallback) GET(e, sc, "/api/login", handler.HandleLogin)
GET(e, sc, "/api/login/callback", handler.HandleLoginCallback)
GET(e, sc, "/api/session", handler.HandleCheckSession) GET(e, sc, "/api/session", handler.HandleCheckSession)
GET(e, sc, "/api/invoice/:id", GET(e, sc, "/api/invoice/:id",
handler.HandleInvoiceStatus, handler.HandleInvoiceStatus,

View File

@ -25,6 +25,11 @@ func New(ctx ServerContext) *Server {
Format: "${time_custom} ${method} ${uri} ${status}\n", Format: "${time_custom} ${method} ${uri} ${status}\n",
CustomTimeFormat: "2006-01-02 15:04:05.00000-0700", CustomTimeFormat: "2006-01-02 15:04:05.00000-0700",
})) }))
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"http://localhost:4224", "https://delphi.market", "https://dev1.delphi.market"},
AllowCredentials: true,
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept},
}))
e.HTTPErrorHandler = httpErrorHandler e.HTTPErrorHandler = httpErrorHandler
s = &Server{e} s = &Server{e}

View File

@ -4,12 +4,13 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0"> <meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico"> <link rel="icon" href="/favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title> <script type="module" src="/src/main.js"></script>
<title>delphi.market</title>
</head> </head>
<body> <body>
<noscript> <noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> <strong>We're sorry but this site doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript> </noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->

View File

@ -16,4 +16,4 @@
"scripthost" "scripthost"
] ]
} }
} }

View File

@ -3,27 +3,30 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "dev": "vite --port 4224",
"build": "vue-cli-service build", "build": "vite build",
"lint": "vue-cli-service lint" "serve": "vite preview"
}, },
"dependencies": { "dependencies": {
"@vitejs/plugin-vue": "^4.4.0",
"core-js": "^3.8.3", "core-js": "^3.8.3",
"pinia": "^2.1.7",
"register-service-worker": "^1.7.2", "register-service-worker": "^1.7.2",
"vue": "^3.2.13" "vite": "^4.5.0",
"vue": "^3.2.13",
"vue-router": "4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.12.16", "@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16", "@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-pwa": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"@vue/eslint-config-standard": "^6.1.0", "@vue/eslint-config-standard": "^6.1.0",
"autoprefixer": "^10.4.16",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-plugin-import": "^2.25.3", "eslint-plugin-import": "^2.25.3",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.1.0", "eslint-plugin-promise": "^5.1.0",
"eslint-plugin-vue": "^8.0.3" "eslint-plugin-vue": "^8.0.3",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5"
} }
} }

6
vue/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

View File

@ -1,26 +1,41 @@
<template> <template>
<img alt="Vue logo" src="./assets/logo.png"> <NavBar />
<HelloWorld msg="Welcome to Your Vue.js App" /> <div id="container">
<router-view />
</div>
</template> </template>
<script> <script>
import HelloWorld from './components/HelloWorld.vue' import NavBar from './components/NavBar'
export default { export default {
name: 'App', name: 'App',
components: { components: { NavBar }
HelloWorld
}
} }
</script> </script>
<script setup>
import { useSession } from './stores/session';
const session = useSession()
session.init()
</script>
<style> <style>
html,
body {
background-color: #091833;
color: #ffffff;
}
#app { #app {
font-family: Avenir, Helvetica, Arial, sans-serif; font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
text-align: center; text-align: center;
color: #2c3e50; color: #ffffff;
margin-top: 60px; margin-top: 1em;
}
#container {
margin: 1em;
} }
</style> </style>

View File

@ -1,66 +0,0 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br>
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank"
rel="noopener">babel</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa" target="_blank"
rel="noopener">pwa</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank"
rel="noopener">eslint</a></li>
</ul>
<h3>Essential Links</h3>
<ul>
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a>
</li>
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
</ul>
</div>
</template>
<script>
export default {
name: 'HelloWorld',
props: {
msg: String
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

View File

@ -0,0 +1,5 @@
<template>
<div>home</div>
</template>
<script></script>

View File

@ -0,0 +1,25 @@
<template>
<a v-if="lnurl" :href="'lightning:' + lnurl">
<img v-if="qr" :src="'data:image/png;base64,' + qr" />
</a>
</template>
<script setup>
import { ref } from 'vue';
import { useSession } from '@/stores/session';
let qr = ref(null)
let lnurl = ref(null)
const session = useSession()
await (async () => {
try {
if (session.isAuthenticated) return
const s = await session.login()
qr = s.qr
lnurl = s.lnurl
} catch (err) {
console.error("error:", err.reason || err)
}
})()
</script>

View File

@ -0,0 +1,25 @@
<template>
<!-- eslint-disable -->
<pre>
_ _
| | ___ __ _(_)_ __
| |/ _ \ / _` | | '_ \
| | (_) | (_| | | | | |
|_|\___/ \__, |_|_| |_|
|___/ </pre>
<!-- eslint-enable -->
<Suspense>
<LoginQRCode class="flex justify-center m-3" />
</Suspense>
</template>
<script setup>
import LoginQRCode from './LoginQRCode.vue';
</script>
<style scoped>
pre {
font-family: monospace;
font-weight: bold;
}
</style>

View File

@ -0,0 +1,40 @@
<template>
<header class="flex flex-row text-center justify-center pt-1">
<nav>
<router-link to="/">home</router-link>
<router-link to="/user" v-if="session.isAuthenticated">user</router-link>
<router-link to="/login" v-else href="/login">login</router-link>
</nav>
</header>
</template>
<script setup>
import { useSession } from '@/stores/session'
const session = useSession()
</script>
<style scoped>
nav {
display: flex;
justify-content: center;
}
a {
color: #8787a4;
text-decoration: underline;
}
a:hover {
color: #ffffff;
background: #8787A4;
}
a.selected {
color: #ffffff;
background: #8787A4;
}
nav>a {
margin: 0 3px;
}
</style>

3
vue/src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -1,5 +1,29 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia'
import * as VueRouter from 'vue-router'
import App from './App.vue' import App from './App.vue'
import './registerServiceWorker' import './registerServiceWorker'
import './index.css'
createApp(App).mount('#app') import HomeView from '@/components/HomeView'
import LoginView from '@/components/LoginView'
const routes = [
{
path: '/', component: HomeView
},
{
path: '/login', component: LoginView
}
]
const router = VueRouter.createRouter({
history: VueRouter.createWebHashHistory(),
routes
})
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
app.mount('#app')

40
vue/src/stores/session.js Normal file
View File

@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useSession = defineStore('session', () => {
let pubkey = ref(null)
// eslint-disable-next-line vue/no-ref-as-operand
const isAuthenticated = computed(() => !!pubkey)
async function init () {
try {
const { pubkey } = await checkSession()
if (pubkey) {
console.log('authenticated as', pubkey)
return
}
console.log('unauthenticated')
} catch (err) {
console.error('error:', err.reason || err)
}
}
function checkSession () {
const url = window.origin + '/api/session'
return fetch(url, {
credentials: 'include'
})
.then(r => {
const body = r.json()
pubkey = body.pubkey
return body
})
}
function login () {
const url = window.origin + '/api/login'
return fetch(url, { credentials: 'include' }).then(r => r.json())
}
return { pubkey, isAuthenticated, init, checkSession, login }
})

10
vue/tailwind.config.js Normal file
View File

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/**/*.{html,js,vue}'
],
theme: {
extend: {}
},
plugins: []
}

14
vue/vite.config.js Normal file
View File

@ -0,0 +1,14 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})

File diff suppressed because it is too large Load Diff