Skip to content

Commit

Permalink
refactor: move login.html into a login component (go-shiori#1017)
Browse files Browse the repository at this point in the history
* feat: Add login component JavaScript file

* feat: Create login component and refactor login view

* refactor: Convert login to single-page application with dynamic component rendering

* feat: Enhance session validation and login form display logic

* fix: Resolve Vue app mounting and method duplication issues

* fix: Prevent null reference error when focusing username input

* fix: Initialize `isLoggedIn` to true to show login form during async check

* refactor: Improve session validation and login flow logic

* fix: Adjust login component visibility and initial login state

* feat: Add login form template to login component

* feat: Update login template to match original login.html design

* fix: Resolve login view rendering and state management issues

* refactor: Remove login route from frontend routes

* refactor: Remove login-footer from login component template

* fix: Modify logout to show login form without redirecting

* refactor: Remove /login route test for SPA architecture

* refactor: delete login.html file

* style: Remove extra blank line in frontend_test.go

* chore: run make style changes
  • Loading branch information
fmartingr authored Dec 9, 2024
1 parent 617f5dd commit fb51755
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 187 deletions.
6 changes: 0 additions & 6 deletions internal/http/routes/frontend.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,6 @@ type FrontendRoutes struct {

func (r *FrontendRoutes) Setup(e *gin.Engine) {
group := e.Group("/")
group.GET("/login", func(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "login.html", gin.H{
"RootPath": r.cfg.Http.RootPath,
"Version": model.BuildVersion,
})
})
group.GET("/", func(ctx *gin.Context) {
ctx.HTML(http.StatusOK, "index.html", gin.H{
"RootPath": r.cfg.Http.RootPath,
Expand Down
7 changes: 0 additions & 7 deletions internal/http/routes/frontend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,6 @@ func TestFrontendRoutes(t *testing.T) {
require.Equal(t, 200, w.Code)
})

t.Run("/login", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/login", nil)
g.ServeHTTP(w, req)
require.Equal(t, 200, w.Code)
})

t.Run("/css/style.css", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/assets/css/style.css", nil)
Expand Down
152 changes: 152 additions & 0 deletions internal/view/assets/js/component/login.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
const template = `
<div id="login-scene">
<p class="error-message" v-if="error !== ''">{{error}}</p>
<div id="login-box">
<form @submit.prevent="login">
<div id="logo-area">
<p id="logo">
<span>栞</span>shiori
</p>
<p id="tagline">simple bookmark manager</p>
</div>
<div id="input-area">
<label for="username">Username: </label>
<input id="username" type="text" name="username" placeholder="Username" tabindex="1" autofocus />
<label for="password">Password: </label>
<input id="password" type="password" name="password" placeholder="Password" tabindex="2"
@keyup.enter="login">
<label class="checkbox-field"><input type="checkbox" name="remember" v-model="remember"
tabindex="3">Remember me</label>
</div>
<div id="button-area">
<a v-if="loading">
<i class="fas fa-fw fa-spinner fa-spin"></i>
</a>
<a v-else class="button" tabindex="4" @click="login" @keyup.enter="login">Log In</a>
</div>
</form>
</div>
</div>
`;

export default {
name: "login-view",
template,
data() {
return {
error: "",
loading: false,
username: "",
password: "",
remember: false,
};
},
emits: ["login-success"],
methods: {
async getErrorMessage(err) {
switch (err.constructor) {
case Error:
return err.message;
case Response:
var text = await err.text();

// Handle new error messages
if (text[0] == "{") {
var json = JSON.parse(text);
return json.message;
}
return `${text} (${err.status})`;
default:
return err;
}
},
parseJWT(token) {
try {
return JSON.parse(atob(token.split(".")[1]));
} catch (e) {
return null;
}
},
login() {
// Get values directly from the form
const usernameInput = document.querySelector("#username");
const passwordInput = document.querySelector("#password");
this.username = usernameInput ? usernameInput.value : this.username;
this.password = passwordInput ? passwordInput.value : this.password;

// Validate input
if (this.username === "") {
this.error = "Username must not empty";
return;
}

// Remove old cookie
document.cookie = `session-id=; Path=${
new URL(document.baseURI).pathname
}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;

// Send request
this.loading = true;

fetch(new URL("api/v1/auth/login", document.baseURI), {
method: "post",
body: JSON.stringify({
username: this.username,
password: this.password,
remember_me: this.remember == 1 ? true : false,
}),
headers: { "Content-Type": "application/json" },
})
.then((response) => {
if (!response.ok) throw response;
return response.json();
})
.then((json) => {
// Save session id
document.cookie = `session-id=${json.message.session}; Path=${
new URL(document.baseURI).pathname
}; Expires=${json.message.expires}`;
document.cookie = `token=${json.message.token}; Path=${
new URL(document.baseURI).pathname
}; Expires=${json.message.expires}`;

// Save account data
localStorage.setItem("shiori-token", json.message.token);
localStorage.setItem(
"shiori-account",
JSON.stringify(this.parseJWT(json.message.token).account),
);

this.visible = false;
this.$emit("login-success");
})
.catch((err) => {
this.loading = false;
this.getErrorMessage(err).then((msg) => {
this.error = msg;
});
});
},
},
mounted() {
// Clear any existing cookies
document.cookie = `session-id=; Path=${
new URL(document.baseURI).pathname
}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;
document.cookie = `token=; Path=${
new URL(document.baseURI).pathname
}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;

// Clear local storage
localStorage.removeItem("shiori-account");
localStorage.removeItem("shiori-token");

// <input autofocus> wasn't working all the time, so I'm putting this here as a fallback
this.$nextTick(() => {
const usernameInput = document.querySelector("#username");
if (usernameInput) {
usernameInput.focus();
}
});
},
};
76 changes: 65 additions & 11 deletions internal/view/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
</head>

<body>
<div id="main-scene">
<div id="app">
<login-view v-if="isLoggedIn === false" @login-success="onLoginSuccess"></login-view>
<div id="main-scene" v-else-if="isLoggedIn === true">
<div id="main-sidebar">
<a v-for="item in sidebarItems" :title="item.title" :class="{active: activePage === item.page}" @click="switchPage(item.page)">
<i class="fas fa-fw" :class="item.icon"></i>
Expand All @@ -35,25 +37,29 @@
<component :is="activePage" :active-account="activeAccount" :app-options="appOptions" @setting-changed="saveSetting"></component>
</keep-alive>
<custom-dialog v-bind="dialog" />
</div>
</div>

<script type="module">
import basePage from "./assets/js/page/base.js";
import LoginComponent from "./assets/js/component/login.js";
import pageHome from "./assets/js/page/home.js";
import pageSetting from "./assets/js/page/setting.js";
import customDialog from "./assets/js/component/dialog.js";
import EventBus from "./assets/js/component/eventBus.js";
Vue.prototype.$bus = EventBus;

var app = new Vue({
el: '#main-scene',
el: '#app',
mixins: [basePage],
components: {
pageHome,
pageSetting,
customDialog
customDialog,
'login-view': LoginComponent
},
data: {
isLoggedIn: false,
activePage: "page-home",
sidebarItems: [{
title: "Home",
Expand All @@ -72,8 +78,8 @@
url = new Url;

if (page === 'page-home' && this.activePage === 'page-home') {
Vue.prototype.$bus.$emit('clearHomePage', {});
}
Vue.prototype.$bus.$emit('clearHomePage', {});
}
url.hash = pageName;
this.activePage = page;
history.pushState(state, page, url);
Expand All @@ -95,7 +101,8 @@
localStorage.removeItem("shiori-account");
localStorage.removeItem("shiori-token");
document.cookie = `session-id=; Path=${new URL(document.baseURI).pathname}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;
location.href = new URL("login", document.baseURI);
document.cookie = `token=; Path=${new URL(document.baseURI).pathname}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;
this.isLoggedIn = false;
}).catch(err => {
this.dialog.loading = false;
this.getErrorMessage(err).then(msg => {
Expand Down Expand Up @@ -133,7 +140,6 @@
MakePublic: MakePublic,
};
this.themeSwitch(Theme)

},
loadAccount() {
var account = JSON.parse(localStorage.getItem("shiori-account")) || {},
Expand All @@ -146,12 +152,60 @@
username: username,
owner: owner,
};
},

onLoginSuccess() {
this.loadSetting();
this.loadAccount();
this.isLoggedIn = true;
},

async validateSession() {
const token = localStorage.getItem("shiori-token");
const account = localStorage.getItem("shiori-account");

if (!(token && account)) {
return false;
}

try {
const response = await fetch(new URL("api/v1/auth/check", document.baseURI), {
headers: {
"Authorization": `Bearer ${token}`
}
});

if (!response.ok) {
throw new Error('Invalid session');
}

return true;
} catch (err) {
// Clear invalid session data
localStorage.removeItem("shiori-account");
localStorage.removeItem("shiori-token");
document.cookie = `session-id=; Path=${new URL(document.baseURI).pathname}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;
document.cookie = `token=; Path=${new URL(document.baseURI).pathname}; Expires=Thu, 01 Jan 1970 00:00:00 GMT;`;
return false;
}
},

async checkLoginStatus() {
const isValid = await this.validateSession();
this.isLoggedIn = isValid;

if (isValid) {
this.loadSetting();
this.loadAccount();
}
}
},
mounted() {
// Load setting
this.loadSetting();
this.loadAccount();
async mounted() {
await this.checkLoginStatus();
if (this.isLoggedIn) {
this.loadSetting();
this.loadAccount();
}

// Prepare history state watcher
var stateWatcher = (e) => {
Expand Down
Loading

0 comments on commit fb51755

Please sign in to comment.