fix: fixes to session storage
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m27s

This commit is contained in:
domrichardson
2026-03-26 10:06:07 +00:00
parent 6774c401bf
commit 6e642da57a
17 changed files with 498 additions and 275 deletions

View File

@@ -2,7 +2,6 @@
<div class="note-editor">
<div class="editor-toolbar mb-3">
<button class="btn btn-sm btn-primary" @click="saveNote">Save</button>
<button v-if="canDelete" class="btn btn-sm btn-danger ms-2" @click="confirmDelete">Delete</button>
<button class="btn btn-sm btn-outline-secondary ms-2" @click="emit('cancel')">Cancel</button>
<button
v-if="fileExplorerEnabled"
@@ -82,6 +81,15 @@
</select>
<input v-if="passwordAction === 'set'" v-model="notePassword" type="password" class="form-control mt-2" minlength="4" maxlength="128" placeholder="Enter a note password" />
</div>
<section v-if="canDelete && editingNote.id" class="danger-zone mt-4" aria-labelledby="danger-zone-title">
<h3 id="danger-zone-title" class="danger-zone-title mb-2">Danger Zone</h3>
<p class="danger-zone-copy mb-3">Deleting this note is permanent and cannot be undone.</p>
<button class="btn btn-danger" type="button" @click="confirmDelete">
<i class="mdi mdi-delete-outline me-1" aria-hidden="true"></i>
Delete Note
</button>
</section>
</div>
</div>
</template>
@@ -91,7 +99,6 @@ import { ref, computed, watch, onBeforeUnmount, onMounted, nextTick } from "vue"
import { marked } from "marked";
import DOMPurify from "dompurify";
import { useSettingsStore } from "../stores/settingsStore";
import { useAuthStore } from "../stores/authStore";
import { preprocessMarkdown } from "../utils/markdown.js";
import FileExplorer from "./FileExplorer.vue";
@@ -116,7 +123,6 @@ const props = defineProps({
const emit = defineEmits(["save", "delete", "cancel"]);
const settingsStore = useSettingsStore();
const authStore = useAuthStore();
const publicSharingEnabled = ref(true);
const fileExplorerEnabled = computed(() => settingsStore.fileExplorerEnabled);
@@ -133,17 +139,7 @@ const saveStateTimeout = ref(null);
const renderedMarkdown = computed(() => {
const html = marked.parse(preprocessMarkdown(editingNote.value.content || ""));
let clean = DOMPurify.sanitize(html);
// Inject access token into space file API URLs so images render without a separate JS fetch
const token = authStore.accessToken;
if (token && props.spaceId) {
clean = clean.replace(/((?:src|href)=["'])([^"']*\/api\/v1\/spaces\/[^"']*\/files\/object[^"']*)(["'])/g, (_, attr, url, quote) => {
if (url.includes("token=")) return attr + url + quote;
const sep = url.includes("?") ? "&" : "?";
return `${attr}${url}${sep}token=${encodeURIComponent(token)}${quote}`;
});
}
return clean;
return DOMPurify.sanitize(html);
});
const saveStatusLabel = computed(() => {
@@ -294,7 +290,7 @@ onMounted(async () => {
.editor-textarea {
font-family: "Courier New", monospace;
min-height: 400px;
min-height: 600px;
resize: vertical;
}
@@ -333,4 +329,22 @@ onMounted(async () => {
overflow-y: auto;
max-height: 600px;
}
.danger-zone {
padding: 1rem;
border: 1px solid #f3b5b5;
border-radius: 0.75rem;
background: #fff5f5;
}
.danger-zone-title {
color: #9f1c1c;
font-size: 1rem;
font-weight: 700;
}
.danger-zone-copy {
color: #7a2727;
font-size: 0.9rem;
}
</style>

View File

@@ -32,7 +32,6 @@
import { computed } from "vue";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { useAuthStore } from "../stores/authStore";
import { preprocessMarkdown } from "../utils/markdown.js";
const props = defineProps({
@@ -50,20 +49,9 @@ const props = defineProps({
},
});
const authStore = useAuthStore();
const renderedMarkdown = computed(() => {
const html = marked.parse(preprocessMarkdown(props.note.content || ""));
let clean = DOMPurify.sanitize(html);
const token = authStore.accessToken;
if (token && props.spaceId) {
clean = clean.replace(/((?:src|href)=["'])([^"']*\/api\/v1\/spaces\/[^"']*\/files\/object[^"']*)(["'])/g, (_, attr, url, quote) => {
if (url.includes("token=")) return attr + url + quote;
const sep = url.includes("?") ? "&" : "?";
return `${attr}${url}${sep}token=${encodeURIComponent(token)}${quote}`;
});
}
return clean;
return DOMPurify.sanitize(html);
});
const categoryLabel = computed(() => {

View File

@@ -88,73 +88,33 @@ const startProviderLogin = (providerId) => {
window.location.href = `${apiClient.defaults.baseURL}/api/v1/auth/providers/${providerId}/start`;
};
const decodeBase64Url = (value) => {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padding = normalized.length % 4;
const padded = padding === 0 ? normalized : `${normalized}${"=".repeat(4 - padding)}`;
return atob(padded);
};
const decodeBase64UrlUTF8 = (value) => {
const binary = decodeBase64Url(value);
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
return new TextDecoder().decode(bytes);
};
const readUserFromQuery = (params) => {
const plainUserJSON = params.get("user_json");
if (plainUserJSON) {
return JSON.parse(plainUserJSON);
}
const encodedUser = params.get("user");
if (encodedUser) {
return JSON.parse(decodeBase64UrlUTF8(encodedUser));
}
return null;
};
const completeOAuthRedirect = async () => {
const params = new URLSearchParams(window.location.search);
const status = params.get("status");
const accessToken = params.get("access_token") || params.get("accessToken") || params.get("token");
if (status === "oauth_error") {
error.value = params.get("message") || "Provider sign-in failed.";
return true;
}
// Accept callback payloads even when `status` is missing.
if (status !== "oauth_success" && !accessToken) {
if (status === "oauth_error") {
error.value = params.get("message") || "Provider sign-in failed.";
}
if (status !== "oauth_success") {
return false;
}
if (!accessToken) {
error.value = "Provider sign-in returned an incomplete session.";
try {
await authStore.ensureInitialized();
} catch {
error.value = "Unable to restore provider session.";
return true;
}
try {
const user = readUserFromQuery(params);
if (!user) {
error.value = "Provider sign-in returned an incomplete session.";
return true;
}
authStore.setSession({ access_token: accessToken, user });
await router.replace("/");
} catch {
error.value = "Unable to restore the provider session.";
}
if (authStore.isAuthenticated) {
window.location.replace("/");
await router.replace("/");
return true;
}
error.value = "Provider sign-in returned an incomplete session.";
return true;
};
@@ -163,6 +123,8 @@ onMounted(async () => {
registrationEnabled.value = !!flags.registration_enabled;
providerLoginEnabled.value = !!flags.provider_login_enabled;
await authStore.ensureInitialized();
if (authStore.isAuthenticated) {
await router.replace("/");
return;

View File

@@ -4,39 +4,6 @@ import { useSettingsStore } from "../stores/settingsStore";
import LoginPage from "../pages/Login.vue";
import RegisterPage from "../pages/Register.vue";
const decodeBase64UrlUTF8 = (value) => {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padding = normalized.length % 4;
const padded = padding === 0 ? normalized : `${normalized}${"=".repeat(4 - padding)}`;
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
return new TextDecoder().decode(bytes);
};
const restoreOAuthSessionFromQuery = (query, authStore) => {
// Merge router query with URLSearchParams for full coverage
const params = new URLSearchParams(window.location.search);
const accessToken = query.access_token || query.accessToken || query.token || params.get("access_token") || params.get("accessToken") || params.get("token");
if (!accessToken) {
return false;
}
try {
const plainUserJSON = query.user_json || params.get("user_json");
const encodedUser = query.user || params.get("user");
const user = plainUserJSON ? JSON.parse(plainUserJSON) : encodedUser ? JSON.parse(decodeBase64UrlUTF8(encodedUser)) : null;
if (!user) {
return false;
}
authStore.setSession({ access_token: accessToken, user });
return true;
} catch {
return false;
}
};
const routes = [
{
path: "/login",
@@ -81,25 +48,7 @@ router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
const settingsStore = useSettingsStore();
// Only attempt OAuth callback restoration if actual OAuth query params are present
const params = new URLSearchParams(window.location.search);
const hasOAuthParams = to.query.access_token || to.query.accessToken || to.query.token || params.get("access_token") || params.get("accessToken") || params.get("token");
if (to.path === "/login") {
if (hasOAuthParams) {
const restored = restoreOAuthSessionFromQuery(to.query, authStore);
if (restored) {
next({ path: "/", replace: true });
return;
}
}
// Allow login page to be viewed regardless of auth state if no OAuth callback
if (!hasOAuthParams) {
next();
return;
}
}
await authStore.ensureInitialized();
if (to.path === "/register") {
await settingsStore.loadFeatureFlags();

View File

@@ -3,23 +3,57 @@ import { useAuthStore } from "../stores/authStore";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080",
withCredentials: true,
});
apiClient.interceptors.request.use((config) => {
const authStore = useAuthStore();
if (authStore.accessToken) {
config.headers.Authorization = `Bearer ${authStore.accessToken}`;
}
return config;
});
let isRefreshing = false;
let refreshSubscribers = [];
function onRefreshed() {
refreshSubscribers.forEach((cb) => cb());
refreshSubscribers = [];
}
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const authStore = useAuthStore();
authStore.logout();
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
// Avoid retrying the refresh request itself
if (originalRequest.url?.includes("/auth/refresh") || originalRequest.url?.includes("/auth/login")) {
const authStore = useAuthStore();
authStore.clearSession();
return Promise.reject(error);
}
if (isRefreshing) {
// Queue the request until the ongoing refresh completes
return new Promise((resolve, reject) => {
refreshSubscribers.push(() => {
originalRequest._retry = true;
apiClient(originalRequest).then(resolve).catch(reject);
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
await apiClient.post("/api/v1/auth/refresh");
onRefreshed();
return apiClient(originalRequest);
} catch {
refreshSubscribers = [];
const authStore = useAuthStore();
authStore.clearSession();
return Promise.reject(error);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
},
);

View File

@@ -3,10 +3,11 @@ import { ref, computed } from "vue";
import apiClient from "../services/apiClient";
export const useAuthStore = defineStore("auth", () => {
const storedUser = localStorage.getItem("user");
const user = ref(storedUser ? JSON.parse(storedUser) : null);
const accessToken = ref(localStorage.getItem("accessToken"));
const isAuthenticated = computed(() => !!accessToken.value && !!user.value);
const user = ref(null);
const initialized = ref(false);
let initPromise = null;
const isAuthenticated = computed(() => !!user.value);
const isAdmin = computed(() => hasPermission("*") || hasPermission("admin.access"));
const normalizePermission = (permission) => (permission || "").trim().toLowerCase();
@@ -46,10 +47,36 @@ export const useAuthStore = defineStore("auth", () => {
};
const setSession = (responseData) => {
accessToken.value = responseData.access_token;
user.value = responseData.user;
localStorage.setItem("accessToken", accessToken.value);
localStorage.setItem("user", JSON.stringify(user.value));
user.value = responseData?.user || null;
initialized.value = true;
};
const clearSession = () => {
user.value = null;
initialized.value = true;
};
const loadSession = async () => {
try {
const response = await apiClient.get("/api/v1/auth/me");
user.value = response.data?.user || null;
} catch {
user.value = null;
} finally {
initialized.value = true;
}
};
const ensureInitialized = async () => {
if (initialized.value) {
return;
}
if (!initPromise) {
initPromise = loadSession().finally(() => {
initPromise = null;
});
}
await initPromise;
};
const register = async (email, username, password, firstName = "", lastName = "") => {
@@ -87,20 +114,20 @@ export const useAuthStore = defineStore("auth", () => {
};
const logout = () => {
accessToken.value = null;
user.value = null;
localStorage.removeItem("accessToken");
localStorage.removeItem("user");
apiClient.post("/api/v1/auth/logout").catch(() => {});
clearSession();
};
return {
user,
accessToken,
initialized,
isAuthenticated,
isAdmin,
hasPermission,
hasSpacePermission,
setSession,
clearSession,
ensureInitialized,
register,
login,
logout,