fix: fixes to session storage
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m27s
All checks were successful
Build and Push App Image / build-and-push (push) Successful in 1m27s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user