Initial app
This commit is contained in:
parent
e9852f9179
commit
6bc3f0cf45
11 changed files with 1755 additions and 1 deletions
1
.env.example
Normal file
1
.env.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_KANIDM_BASE_URL=https://kanidm.example.com
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -9,3 +9,6 @@ docs/_book
|
|||
# TODO: where does this rule come from?
|
||||
test/
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
|
|
|
|||
50
README.md
50
README.md
|
|
@ -1,3 +1,51 @@
|
|||
# kanidm-up-pass
|
||||
|
||||
A simple GUI to reset Kanidm User & Posix password at the same time
|
||||
A small Vue interface to reset Kanidm user and POSIX passwords to the same value from a credential reset token.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Build for production:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open the app with a reset token in the URL:
|
||||
`http://localhost:5173/?token=<credential-update-intent-token>`
|
||||
2. Enter your new password and confirm it.
|
||||
3. Submit.
|
||||
|
||||
The app connects directly to Kanidm:
|
||||
|
||||
1. `POST /v1/credential/_exchange_intent` with the token.
|
||||
2. `POST /v1/credential/_update` with `password`.
|
||||
3. `POST /v1/credential/_update` with `unixpassword`.
|
||||
4. `POST /v1/credential/_commit`.
|
||||
|
||||
The UI supports English and German and lets users switch language in the page header.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set your Kanidm base URL in `.env`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
VITE_KANIDM_BASE_URL=https://kanidm.example.com
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The token is read from the URL and then removed from the browser address bar.
|
||||
- No backend is used; browser CORS access to Kanidm must be allowed for your app origin.
|
||||
12
index.html
Normal file
12
index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kanidm Password Reset</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1296
package-lock.json
generated
Normal file
1296
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
package.json
Normal file
19
package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "kanidm-up-pass",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.18",
|
||||
"vue-i18n": "^11.4.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"vite": "^5.4.19"
|
||||
}
|
||||
}
|
||||
179
src/App.vue
Normal file
179
src/App.vue
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { supportedLocales } from "./i18n";
|
||||
|
||||
const baseUrl = (import.meta.env.VITE_KANIDM_BASE_URL || "").replace(/\/+$/, "");
|
||||
const customName = (import.meta.env.VITE_KANIDM_NAME || "").trim();
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const token = ref(readTokenFromUrl());
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
const success = ref("");
|
||||
|
||||
const tokenPresent = computed(() => token.value.length > 0);
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
tokenPresent.value &&
|
||||
password.value.length > 0 &&
|
||||
confirmPassword.value.length > 0 &&
|
||||
!busy.value
|
||||
);
|
||||
|
||||
function readTokenFromUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
const value = (url.searchParams.get("token") || "").trim();
|
||||
if (value) {
|
||||
url.searchParams.delete("token");
|
||||
window.history.replaceState({}, document.title, url.toString());
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function postJson(path, body, contentType = "application/json") {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
Accept: "application/json"
|
||||
},
|
||||
body: contentType === "application/json" ? JSON.stringify(body) : body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || `Request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function parseExchangeResponse(data) {
|
||||
if (Array.isArray(data) && data.length >= 1 && data[0]?.token) {
|
||||
return data[0].token;
|
||||
}
|
||||
if (data?.session_token?.token) {
|
||||
return data.session_token.token;
|
||||
}
|
||||
if (data?.token) {
|
||||
return data.token;
|
||||
}
|
||||
throw new Error(t("errUnexpectedExchange"));
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
|
||||
if (!baseUrl) {
|
||||
error.value = t("errMissingBaseUrl");
|
||||
return;
|
||||
}
|
||||
if (!tokenPresent.value) {
|
||||
error.value = t("errMissingToken");
|
||||
return;
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = t("errPasswordsMismatch");
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
try {
|
||||
const exchangeResponse = await postJson(
|
||||
"/v1/credential/_exchange_intent",
|
||||
token.value,
|
||||
"text/plain"
|
||||
);
|
||||
const sessionToken = parseExchangeResponse(exchangeResponse);
|
||||
|
||||
await postJson("/v1/credential/_update", [
|
||||
{ password: password.value },
|
||||
{ token: sessionToken }
|
||||
]);
|
||||
await postJson("/v1/credential/_update", [
|
||||
{ unixpassword: password.value },
|
||||
{ token: sessionToken }
|
||||
]);
|
||||
await postJson("/v1/credential/_commit", { token: sessionToken });
|
||||
|
||||
password.value = "";
|
||||
confirmPassword.value = "";
|
||||
success.value = t("successReset");
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : t("errResetFailed");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setLocale(nextLocale) {
|
||||
if (!supportedLocales.includes(nextLocale)) return;
|
||||
locale.value = nextLocale;
|
||||
localStorage.setItem("locale", nextLocale);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="card">
|
||||
<div class="toolbar">
|
||||
<h1>{{ customName || t("title") }}</h1>
|
||||
<label class="locale-field" for="locale-select">
|
||||
<span>{{ t("language") }}</span>
|
||||
<select
|
||||
id="locale-select"
|
||||
:value="locale"
|
||||
@change="setLocale($event.target.value)"
|
||||
>
|
||||
<option value="en">{{ t("languageEnglish") }}</option>
|
||||
<option value="de">{{ t("languageGerman") }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p>{{ t("subtitle") }}</p>
|
||||
|
||||
<template v-if="!tokenPresent">
|
||||
<div class="message error">
|
||||
{{ t("missingTokenHelp") }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<form @submit.prevent="submit">
|
||||
<label for="password">{{ t("newPassword") }}</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
autocomplete="new-password"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
|
||||
<label for="confirm">{{ t("confirmPassword") }}</label>
|
||||
<input
|
||||
id="confirm"
|
||||
v-model="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
|
||||
<button :disabled="!canSubmit">
|
||||
{{ busy ? t("resetting") : t("resetPassword") }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<div v-if="error" class="message error">{{ error }}</div>
|
||||
<div v-if="success" class="message ok">{{ success }}</div>
|
||||
<p class="hint">{{ t("targetApi") }}: {{ baseUrl || t("notConfigured") }}</p>
|
||||
</main>
|
||||
</template>
|
||||
65
src/i18n.js
Normal file
65
src/i18n.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { createI18n } from "vue-i18n";
|
||||
|
||||
export const supportedLocales = ["en", "de"];
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
language: "Language",
|
||||
languageEnglish: "English",
|
||||
languageGerman: "Deutsch",
|
||||
title: "IDM password reset",
|
||||
subtitle: "Reset your password.",
|
||||
missingTokenHelp: "Missing reset token. Open this page with ?token=...",
|
||||
newPassword: "New password",
|
||||
confirmPassword: "Confirm password",
|
||||
resetting: "Resetting...",
|
||||
resetPassword: "Reset password",
|
||||
targetApi: "Target API",
|
||||
notConfigured: "not configured",
|
||||
errMissingBaseUrl: "VITE_KANIDM_BASE_URL is not configured.",
|
||||
errMissingToken: "Reset token is missing.",
|
||||
errPasswordsMismatch: "Passwords do not match.",
|
||||
errUnexpectedExchange: "Unexpected Kanidm exchange response.",
|
||||
successReset: "Password reset succeeded.",
|
||||
errResetFailed: "Reset failed."
|
||||
},
|
||||
de: {
|
||||
language: "Sprache",
|
||||
languageEnglish: "English",
|
||||
languageGerman: "Deutsch",
|
||||
title: "IDM Passwortreset",
|
||||
subtitle: "Setzen Sie Ihr Passwort zurück.",
|
||||
missingTokenHelp:
|
||||
"Fehlender Reset-Token. Öffnen Sie diese Seite mit ?token=...",
|
||||
newPassword: "Neues Passwort",
|
||||
confirmPassword: "Passwort bestätigen",
|
||||
resetting: "Wird zurückgesetzt...",
|
||||
resetPassword: "Passwort zurücksetzen",
|
||||
targetApi: "Ziel-API",
|
||||
notConfigured: "nicht konfiguriert",
|
||||
errMissingBaseUrl: "VITE_KANIDM_BASE_URL ist nicht konfiguriert.",
|
||||
errMissingToken: "Reset-Token fehlt.",
|
||||
errPasswordsMismatch: "Passwörter stimmen nicht überein.",
|
||||
errUnexpectedExchange: "Unerwartete Kanidm-Antwort beim Token-Austausch.",
|
||||
successReset:
|
||||
"Passwort wurde erfolgreich zurückgesetzt.",
|
||||
errResetFailed: "Zurücksetzen fehlgeschlagen."
|
||||
}
|
||||
};
|
||||
|
||||
function detectLocale() {
|
||||
const saved = localStorage.getItem("locale");
|
||||
if (saved && supportedLocales.includes(saved)) {
|
||||
return saved;
|
||||
}
|
||||
|
||||
const language = (navigator.language || "en").split("-")[0];
|
||||
return supportedLocales.includes(language) ? language : "en";
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: detectLocale(),
|
||||
fallbackLocale: "en",
|
||||
messages
|
||||
});
|
||||
6
src/main.js
Normal file
6
src/main.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
import { i18n } from "./i18n";
|
||||
|
||||
createApp(App).use(i18n).mount("#app");
|
||||
119
src/styles.css
Normal file
119
src/styles.css
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(92vw, 420px);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
background: #1f2937;
|
||||
color: #f9fafb;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.toolbar h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.locale-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.locale-field select {
|
||||
min-width: 7.5rem;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.45rem;
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 0.9rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0.75rem 0 0.35rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid #374151;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: #111827;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #7f1d1d;
|
||||
color: #fee2e2;
|
||||
}
|
||||
|
||||
.ok {
|
||||
background: #14532d;
|
||||
color: #dcfce7;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
color: #93c5fd;
|
||||
}
|
||||
6
vite.config.js
Normal file
6
vite.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()]
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue