Use pseudo-Bearer token auth instead of Basic auth
This commit is contained in:
parent
b0190b2ccf
commit
866129dd24
49
index.html
49
index.html
@ -1,23 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="googlebot" content="noindex, nofollow">
|
||||
|
||||
<meta name="theme-color" content="aliceblue" />
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
<title>Directory</title>
|
||||
</head>
|
||||
<body class="bg-back text-front">
|
||||
<div id="app"></div>
|
||||
<noscript>
|
||||
<strong>We're sorry but JavaScript is required. Please enable it to continue.</strong>
|
||||
</noscript>
|
||||
</body>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta name="googlebot" content="noindex, nofollow" />
|
||||
|
||||
<meta name="theme-color" content="aliceblue" />
|
||||
<link rel="icon" href="/src/assets/favicon.ico" />
|
||||
|
||||
<title>Directory Web Access</title>
|
||||
</head>
|
||||
|
||||
<body class="bg-back text-front">
|
||||
<div id="app"></div>
|
||||
<noscript>
|
||||
<strong>
|
||||
We're sorry but JavaScript is required. Please enable it to
|
||||
continue.
|
||||
</strong>
|
||||
</noscript>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
BIN
package-lock.json
generated
BIN
package-lock.json
generated
Binary file not shown.
@ -15,9 +15,9 @@
|
||||
"tw-config": "tailwind-config-viewer -o"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/components": "^10.2.1",
|
||||
"@vueuse/core": "^10.2.1",
|
||||
"@heroicons/vue": "^2.0.10",
|
||||
"font-awesome": "^4.7.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.29",
|
||||
"vue-router": "^4.3.3"
|
||||
},
|
||||
@ -35,8 +35,7 @@
|
||||
"prettier": "^3.2.6",
|
||||
"typescript": "~5.4.0",
|
||||
"vite": "^5.3.1",
|
||||
"vue-tsc": "^2.0.21",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vue-tsc": "^2.0.26",
|
||||
"vitest": "^0.34.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"tailwindcss": "^3.4.4",
|
||||
|
@ -4,13 +4,15 @@
|
||||
from typing import *
|
||||
|
||||
from constants import PASSWORDS, PHOTO, WITH_OPERATIONAL_ATTRS
|
||||
from decorators import no_cache, api, authenticated, connected
|
||||
from decorators import anonymous_api, no_cache, api, authenticated, connected, search_user_by_attr
|
||||
from ldaputils import _at, _dict, _dn_order, _ename, _entry, _oc, _schema, _syntax, _tree
|
||||
import ldaputils
|
||||
from token_utils import issue_jwt_token
|
||||
from utils import empty, result, unique
|
||||
from app_quart import app
|
||||
|
||||
@app.route('/')
|
||||
@app.route('/login')
|
||||
async def index() -> quart.Response:
|
||||
'Serve the main page'
|
||||
return await static_file('index.html')
|
||||
@ -30,6 +32,42 @@ async def whoami() -> str:
|
||||
return request.ldap.whoami_s().replace('dn:', '')
|
||||
|
||||
|
||||
@app.route('/api/login', methods=['POST'])
|
||||
@no_cache
|
||||
@connected
|
||||
@anonymous_api
|
||||
async def login() -> str:
|
||||
'Issue a JWT token containing encrypted user password & login'
|
||||
requestData = await request.get_json();
|
||||
|
||||
get_dn = app.config['GET_BIND_DN']
|
||||
dn = get_dn(requestData) if get_dn else None
|
||||
|
||||
try:
|
||||
if dn is None and requestData["userIdentifier"] is None:
|
||||
raise ldaplib.INVALID_CREDENTIALS()
|
||||
|
||||
# Try authenticating
|
||||
get_passwd = app.config['GET_BIND_PASSWORD']
|
||||
await empty(quart.request.ldap.simple_bind(
|
||||
dn or await search_user_by_attr(),
|
||||
get_passwd(requestData)
|
||||
))
|
||||
|
||||
jwtToken = issue_jwt_token(requestData);
|
||||
|
||||
return quart.Response(
|
||||
quart.json.dumps({ "token": jwtToken }),
|
||||
status = 200
|
||||
)
|
||||
|
||||
except ldaplib.INVALID_CREDENTIALS:
|
||||
return quart.Response(
|
||||
quart.json.dumps({ "error": "Invalid credentials" }),
|
||||
status = 401
|
||||
)
|
||||
|
||||
|
||||
@app.route('/api/tree/<path:basedn>')
|
||||
@no_cache
|
||||
@api
|
||||
@ -177,28 +215,28 @@ async def rename() -> None:
|
||||
async def passwd(dn: str) -> Optional[bool]:
|
||||
'Edit directory entries'
|
||||
|
||||
if request.is_json:
|
||||
args = await request.get_json()
|
||||
if 'check' in args:
|
||||
try:
|
||||
con = ldaplib.initialize(app.config['LDAP_URL'])
|
||||
con.simple_bind_s(dn, args['check'])
|
||||
con.unbind_s()
|
||||
return True
|
||||
except ldaplib.INVALID_CREDENTIALS:
|
||||
return False
|
||||
if not request.is_json:
|
||||
return None;
|
||||
|
||||
elif 'new1' in args:
|
||||
if args['new1']:
|
||||
await empty(request.ldap.passwd(dn, args.get('old') or None, args['new1']))
|
||||
_dn, attrs = await unique(
|
||||
request.ldap.search(dn, ldaplib.SCOPE_BASE))
|
||||
return attrs['userPassword'][0].decode()
|
||||
else:
|
||||
await empty(request.ldap.modify(dn, [(1, 'userPassword', None)]))
|
||||
return ''
|
||||
args = await request.get_json()
|
||||
if 'check' in args:
|
||||
try:
|
||||
con = ldaplib.initialize(app.config['LDAP_URL'])
|
||||
con.simple_bind_s(dn, args['check'])
|
||||
con.unbind_s()
|
||||
return True
|
||||
except ldaplib.INVALID_CREDENTIALS:
|
||||
return False
|
||||
|
||||
return None # mypy
|
||||
elif 'new1' in args:
|
||||
if args['new1']:
|
||||
await empty(request.ldap.passwd(dn, args.get('old') or None, args['new1']))
|
||||
_dn, attrs = await unique(
|
||||
request.ldap.search(dn, ldaplib.SCOPE_BASE))
|
||||
return attrs['userPassword'][0].decode()
|
||||
else:
|
||||
await empty(request.ldap.modify(dn, [(1, 'userPassword', None)]))
|
||||
return ''
|
||||
|
||||
|
||||
@app.route('/api/search/<path:query>')
|
||||
|
@ -5,6 +5,7 @@
|
||||
import quart
|
||||
|
||||
from constants import UNAUTHORIZED
|
||||
from token_utils import decode_jwt_token
|
||||
from utils import empty, unique
|
||||
from app_quart import app
|
||||
|
||||
@ -72,25 +73,26 @@ def authenticated(view: Callable):
|
||||
|
||||
@functools.wraps(view)
|
||||
async def wrapped_view(**values):
|
||||
auth_material = decode_jwt_token(quart.request.authorization.token)
|
||||
|
||||
get_dn = app.config['GET_BIND_DN']
|
||||
dn = get_dn(quart.request.authorization) if get_dn else None
|
||||
dn = get_dn(auth_material) if get_dn and (auth_material is not None) else None
|
||||
|
||||
try:
|
||||
if dn is None and quart.request.authorization is None:
|
||||
if dn is None and auth_material is None:
|
||||
raise ldap.INVALID_CREDENTIALS()
|
||||
|
||||
# Try authenticating
|
||||
get_passwd = app.config['GET_BIND_PASSWORD']
|
||||
await empty(quart.request.ldap.simple_bind(
|
||||
dn or await search_user_by_attr(app),
|
||||
get_passwd(quart.request.authorization)))
|
||||
get_passwd(auth_material)))
|
||||
|
||||
# On success, call the view function
|
||||
return await view(**values)
|
||||
|
||||
except ldap.INVALID_CREDENTIALS:
|
||||
return quart.Response(
|
||||
'Please log in', 401, UNAUTHORIZED)
|
||||
return quart.Response('{"error": "Unauthenticated. Please log in"}', 401, UNAUTHORIZED)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
@ -104,4 +106,17 @@ async def wrapped_view(**values) -> quart.Response:
|
||||
if type(data) is not quart.Response:
|
||||
data = quart.jsonify(data)
|
||||
return data
|
||||
return wrapped_view
|
||||
|
||||
|
||||
def anonymous_api(view: Callable) -> quart.Response:
|
||||
''' View decorator for JSON endpoints.
|
||||
Does not require authentication.
|
||||
'''
|
||||
@functools.wraps(view)
|
||||
async def wrapped_view(**values) -> quart.Response:
|
||||
data = await view(**values)
|
||||
if type(data) is not quart.Response:
|
||||
data = quart.jsonify(data)
|
||||
return data
|
||||
return wrapped_view
|
@ -1,3 +1,4 @@
|
||||
python-ldap
|
||||
python-dotenv
|
||||
Quart
|
||||
cryptography
|
@ -1,5 +1,6 @@
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
import base64
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@ -7,6 +8,19 @@
|
||||
PREFERRED_URL_SCHEME = 'https'
|
||||
SECRET_KEY = os.urandom(16)
|
||||
|
||||
|
||||
def GET_TOKEN_KEY():
|
||||
env_key_b64 = os.environ.get("SECRET_TOKEN_KEY")
|
||||
if env_key_b64 is None:
|
||||
return os.urandom(32)
|
||||
|
||||
decoded = base64.b64decode(env_key_b64)
|
||||
if len(decoded) != 32:
|
||||
raise ValueError("Invalid secret token length, expected 32 bytes got " + str(len(decoded)))
|
||||
return decoded
|
||||
|
||||
SECRET_TOKEN_KEY = GET_TOKEN_KEY()
|
||||
|
||||
#
|
||||
# LDAP settings
|
||||
#
|
||||
@ -36,12 +50,12 @@ def GET_BIND_DN(authorization):
|
||||
# This can be used to authenticate with directories
|
||||
# that do not allow anonymous users to search.
|
||||
elif os.environ.get('BIND_PATTERN') and authorization is not None:
|
||||
return os.environ['BIND_PATTERN'] % authorization.username
|
||||
return os.environ['BIND_PATTERN'] % authorization["userIdentifier"]
|
||||
|
||||
|
||||
def GET_BIND_DN_FILTER(authorization):
|
||||
'Produce a LDAP search filter for the login DN'
|
||||
return SEARCH_PATTERNS[0] % authorization.username
|
||||
return SEARCH_PATTERNS[0] % authorization["userIdentifier"]
|
||||
|
||||
|
||||
def GET_BIND_PASSWORD(authorization):
|
||||
@ -54,9 +68,9 @@ def GET_BIND_PASSWORD(authorization):
|
||||
if pw_file is not None:
|
||||
with open(pw_file) as file:
|
||||
return file.read().rstrip('\n')
|
||||
|
||||
|
||||
if authorization is not None:
|
||||
return authorization.password
|
||||
return authorization["password"]
|
||||
|
||||
|
||||
#
|
||||
|
27
server/token_utils.py
Normal file
27
server/token_utils.py
Normal file
@ -0,0 +1,27 @@
|
||||
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
||||
import json
|
||||
import os
|
||||
import settings
|
||||
import struct
|
||||
import time
|
||||
import base64
|
||||
|
||||
def issue_jwt_token(auth_material):
|
||||
cipher_object = ChaCha20Poly1305(settings.SECRET_TOKEN_KEY)
|
||||
nonce = struct.pack("QI", int(time.time()), struct.unpack("I", os.urandom(4))[0])
|
||||
|
||||
data = json.dumps(auth_material).encode("utf-8")
|
||||
associated_data = nonce
|
||||
|
||||
return base64.b64encode(nonce + cipher_object.encrypt(nonce, data, associated_data)).decode("ascii")
|
||||
|
||||
def decode_jwt_token(aead_token_b64):
|
||||
if aead_token_b64 is None:
|
||||
return None
|
||||
|
||||
aead_token = base64.b64decode(aead_token_b64)
|
||||
|
||||
cipher_object = ChaCha20Poly1305(settings.SECRET_TOKEN_KEY)
|
||||
token = cipher_object.decrypt(aead_token[:12], aead_token[12:], aead_token[:12])
|
||||
|
||||
return json.loads(token.decode("utf-8"))
|
208
src/App.vue
208
src/App.vue
@ -1,206 +1,19 @@
|
||||
<template>
|
||||
<div id="app" v-if="!user">
|
||||
<login-view />
|
||||
</div>
|
||||
<div id="app" v-else>
|
||||
<nav-bar
|
||||
v-model:treeOpen="treeOpen"
|
||||
:dn="activeDn"
|
||||
:base-dn="baseDn"
|
||||
:user="user"
|
||||
@show-modal="modal = $event"
|
||||
@select-dn="activeDn = $event"
|
||||
@show-oc="oc = $event"
|
||||
/>
|
||||
|
||||
<ldif-import-dialog v-model:modal="modal" @ok="activeDn = '-'" />
|
||||
|
||||
<div class="flex container">
|
||||
<div class="space-y-4">
|
||||
<!-- left column -->
|
||||
<tree-view
|
||||
v-model:activeDn="activeDn"
|
||||
v-show="treeOpen"
|
||||
@base-dn="baseDn = $event"
|
||||
/>
|
||||
<object-class-card
|
||||
v-model="oc"
|
||||
@show-attr="attr = $event"
|
||||
@show-oc="oc = $event"
|
||||
/>
|
||||
<attribute-card v-model="attr" @show-attr="attr = $event" />
|
||||
</div>
|
||||
|
||||
<div class="flex-auto mt-4">
|
||||
<!-- main editing area -->
|
||||
<transition name="fade"
|
||||
><!-- Notifications -->
|
||||
<div
|
||||
v-if="error"
|
||||
:class="error.cssClass"
|
||||
class="rounded mx-4 mb-4 p-3 border border-front/70 text-front/70 dark:text-back/70"
|
||||
>
|
||||
{{ error.msg }}
|
||||
<span
|
||||
class="float-right control"
|
||||
@click="error = undefined"
|
||||
>✖</span
|
||||
>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<entry-editor
|
||||
v-model:activeDn="activeDn"
|
||||
:user="user"
|
||||
@show-attr="attr = $event"
|
||||
@show-oc="oc = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, ref, watch } from "vue";
|
||||
import AttributeCard from "./components/schema/AttributeCard.vue";
|
||||
import EntryEditor from "./components/editor/EntryEditor.vue";
|
||||
import { LdapSchema } from "./components/schema/schema";
|
||||
import LdifImportDialog from "@/views/dialogs/LdifImportDialog.vue";
|
||||
import NavBar from "./components/NavBar.vue";
|
||||
import ObjectClassCard from "./components/schema/ObjectClassCard.vue";
|
||||
import type { Provided } from "@/data/provided";
|
||||
import TreeView from "./components/TreeView.vue";
|
||||
import LoginView from "@/views/LoginView.vue";
|
||||
|
||||
interface Error {
|
||||
counter: number;
|
||||
cssClass: string;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
// Authentication
|
||||
const user = ref<string>(); // logged in user
|
||||
const baseDn = ref<string>();
|
||||
const authHeaders = ref<Record<string, string>>({});
|
||||
|
||||
// Components
|
||||
const treeOpen = ref(true); // Is the tree visible?
|
||||
const activeDn = ref<string>(); // currently active DN in the editor
|
||||
const modal = ref<string>(); // modal popup
|
||||
|
||||
// Alerts
|
||||
const error = ref<Error>(); // status alert
|
||||
|
||||
// LDAP schema
|
||||
const schema = ref<LdapSchema>();
|
||||
const oc = ref<string>(); // objectClass info in side panel
|
||||
const attr = ref<string>(); // attribute info in side panel
|
||||
|
||||
// Helpers for components
|
||||
const provided: Provided = {
|
||||
get schema() {
|
||||
return schema.value;
|
||||
},
|
||||
showInfo,
|
||||
showException,
|
||||
showWarning,
|
||||
authHeaders,
|
||||
};
|
||||
|
||||
provide("app", provided);
|
||||
|
||||
onMounted(async () => {
|
||||
// Runs on page load
|
||||
// Get the DN of the current user
|
||||
const whoamiResponse = await fetch("api/whoami");
|
||||
if (whoamiResponse.ok) {
|
||||
user.value = await whoamiResponse.json();
|
||||
}
|
||||
|
||||
// Load the schema
|
||||
const schemaResponse = await fetch("api/schema");
|
||||
if (schemaResponse.ok) {
|
||||
schema.value = new LdapSchema(await schemaResponse.json());
|
||||
}
|
||||
});
|
||||
|
||||
watch(attr, (a) => {
|
||||
if (a) oc.value = undefined;
|
||||
});
|
||||
|
||||
watch(oc, (o) => {
|
||||
if (o) attr.value = undefined;
|
||||
});
|
||||
|
||||
// Display an info popup
|
||||
function showInfo(msg: string) {
|
||||
error.value = { counter: 5, cssClass: "bg-emerald-300", msg: "" + msg };
|
||||
setTimeout(() => {
|
||||
error.value = undefined;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Flash a warning popup
|
||||
function showWarning(msg: string) {
|
||||
error.value = { counter: 10, cssClass: "bg-amber-200", msg: "⚠️ " + msg };
|
||||
setTimeout(() => {
|
||||
error.value = undefined;
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Report an error
|
||||
function showError(msg: string) {
|
||||
error.value = { counter: 60, cssClass: "bg-red-300", msg: "⛔ " + msg };
|
||||
setTimeout(() => {
|
||||
error.value = undefined;
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
function showException(msg: string) {
|
||||
const span = document.createElement("span");
|
||||
span.innerHTML = msg.replace("\n", " ");
|
||||
const titles = span.getElementsByTagName("title");
|
||||
for (let i = 0; i < titles.length; ++i) {
|
||||
span.removeChild(titles[i]);
|
||||
}
|
||||
let text = "";
|
||||
const headlines = span.getElementsByTagName("h1");
|
||||
for (let i = 0; i < headlines.length; ++i) {
|
||||
text = text + headlines[i].textContent + ": ";
|
||||
span.removeChild(headlines[i]);
|
||||
}
|
||||
showError(text + " " + span.textContent);
|
||||
}
|
||||
import { RouterView } from "vue-router";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.control {
|
||||
@apply opacity-70 hover:opacity-90 cursor-pointer select-none leading-none pt-1 pr-1;
|
||||
}
|
||||
|
||||
button,
|
||||
.btn,
|
||||
[type="button"] {
|
||||
@apply px-3 py-2 rounded text-back dark:text-front font-medium outline-none;
|
||||
}
|
||||
|
||||
button.btn {
|
||||
@apply border-solid border-back border-2 focus:border-primary dark:focus:border-front;
|
||||
}
|
||||
|
||||
select {
|
||||
background: url("assets/gray_bg.svg") no-repeat right;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.glyph {
|
||||
font-family: sans-serif, FontAwesome;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
@ -208,4 +21,3 @@ select {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
./data/Provided
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
64
src/assets/main.css
Normal file
64
src/assets/main.css
Normal file
@ -0,0 +1,64 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--color-front: 32 32 32;
|
||||
--color-back: 255 255 255;
|
||||
|
||||
--color-primary: 23 162 184;
|
||||
--color-secondary: 108 117 125;
|
||||
--color-danger: 229 57 53;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
|
||||
--color-front: 144 144 144;
|
||||
--color-back: 24 24 24;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-color);
|
||||
/* line-height: 1.6; */
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
"Fira Sans",
|
||||
"Droid Sans",
|
||||
"Helvetica Neue",
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
font-weight: normal;
|
||||
margin: 0 auto;
|
||||
flex: 1 0 auto;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.footer-shown > #app {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
place-items: center center;
|
||||
}
|
||||
}
|
76
src/components/Collapsible.vue
Normal file
76
src/components/Collapsible.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { XCircleIcon } from "@heroicons/vue/24/outline";
|
||||
import { nextTick, onMounted, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string | null): void;
|
||||
}>();
|
||||
|
||||
const finishedChanging = ref<number | null>(null);
|
||||
const innerElement = ref<HTMLDivElement | null>(null);
|
||||
const style = ref<string | undefined>("height: 0px");
|
||||
const cachedValue = ref<string | null>(props.modelValue ?? null);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (newValue, oldValue) => {
|
||||
style.value = `height: ${
|
||||
oldValue ? innerElement.value?.clientHeight ?? 0 : 0
|
||||
}`;
|
||||
if (finishedChanging.value) clearTimeout(finishedChanging.value);
|
||||
|
||||
if (newValue) cachedValue.value = newValue;
|
||||
await nextTick();
|
||||
|
||||
style.value = `height: ${
|
||||
newValue ? innerElement.value?.clientHeight ?? 0 : 0
|
||||
}px`;
|
||||
|
||||
finishedChanging.value = setTimeout(() => {
|
||||
finishedChanging.value = null;
|
||||
cachedValue.value = newValue;
|
||||
}, 200);
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (finishedChanging.value) clearTimeout(finishedChanging.value);
|
||||
|
||||
style.value = `height: ${
|
||||
props.modelValue ? innerElement.value?.clientHeight ?? 0 : 0
|
||||
}px`;
|
||||
|
||||
finishedChanging.value = setTimeout(
|
||||
() => (finishedChanging.value = null),
|
||||
205
|
||||
);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="modelValue || cachedValue"
|
||||
class="collapsible mx-1 bg-red-900 text-gray-50 rounded relative overflow-hidden"
|
||||
:style="style"
|
||||
>
|
||||
<div ref="innerElement" class="px-3 py-2 whitespace-pre-wrap">
|
||||
<button
|
||||
title="Close Collapsible"
|
||||
type="button"
|
||||
@click="emit('update:modelValue', null)"
|
||||
class="float-right ml-2"
|
||||
>
|
||||
<XCircleIcon class="h-6 w-6 text-gray-50" /></button
|
||||
>{{ cachedValue }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.collapsible {
|
||||
transition: height 0.2s ease-out;
|
||||
}
|
||||
</style>
|
@ -51,7 +51,9 @@ watch(
|
||||
async (q) => {
|
||||
if (!q) return;
|
||||
|
||||
const response = await fetch("api/search/" + q);
|
||||
const response = await fetch("api/search/" + q, {
|
||||
headers: app?.authHeaders.value,
|
||||
});
|
||||
if (!response.ok) return;
|
||||
results.value = (await response.json()) as Result[];
|
||||
|
||||
|
43
src/components/TailwindButton.vue
Normal file
43
src/components/TailwindButton.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { RouteLocationRaw } from "vue-router";
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean;
|
||||
text?: string;
|
||||
to?: RouteLocationRaw;
|
||||
customClass?: string;
|
||||
type?: string;
|
||||
}>();
|
||||
|
||||
const button = ref(null);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "click", value: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
const eventHandler = (event: MouseEvent) => {
|
||||
emit("click", event);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
ref="button"
|
||||
:is="to ? 'RouterLink' : 'button'"
|
||||
@click="eventHandler"
|
||||
:to="to ? to : undefined"
|
||||
:disabled="disabled || false"
|
||||
class="px-1 py-2 border transition-colors transition-200 ease-in"
|
||||
:class="[
|
||||
to ? 'text-center' : '',
|
||||
customClass ??
|
||||
'bg-slate-800 border-slate-700 disabled:bg-zinc-800 disabled:border-zinc-700 hover:bg-slate-700 hover:border-slate-600',
|
||||
]"
|
||||
:type="type"
|
||||
>
|
||||
<slot>
|
||||
{{ text || "" }}
|
||||
</slot>
|
||||
</component>
|
||||
</template>
|
@ -41,9 +41,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DN } from "./schema/schema";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { inject, onMounted, ref, watch } from "vue";
|
||||
import NodeLabel from "./NodeLabel.vue";
|
||||
import type { TreeNode } from "@/data/treeNode";
|
||||
import type { Provided } from "@/data/provided";
|
||||
|
||||
class Node implements TreeNode {
|
||||
dn: string;
|
||||
@ -106,6 +107,7 @@ const props = defineProps({
|
||||
});
|
||||
const tree = ref<Node>();
|
||||
const emit = defineEmits(["base-dn", "update:activeDn"]);
|
||||
const app = inject<Provided>("app");
|
||||
|
||||
onMounted(async () => {
|
||||
await reload("base");
|
||||
@ -157,7 +159,9 @@ async function clicked(dn: string) {
|
||||
|
||||
// Reload the subtree at entry with given DN
|
||||
async function reload(dn: string) {
|
||||
const response = await fetch("api/tree/" + dn);
|
||||
const response = await fetch("api/tree/" + dn, {
|
||||
headers: app?.authHeaders.value,
|
||||
});
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = (await response.json()) as Node[];
|
||||
|
@ -273,7 +273,9 @@ onMounted(async () => {
|
||||
)
|
||||
return;
|
||||
|
||||
const response = await fetch("api/range/" + props.attr.name);
|
||||
const response = await fetch("api/range/" + props.attr.name, {
|
||||
headers: app?.authHeaders.value,
|
||||
});
|
||||
if (!response.ok) return;
|
||||
|
||||
const range = (await response.json()) as {
|
||||
@ -379,6 +381,7 @@ async function deleteBlob(index: number) {
|
||||
"api/blob/" + props.attr.name + "/" + index + "/" + props.meta.dn,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: app?.authHeaders.value,
|
||||
}
|
||||
);
|
||||
if (response.ok) emit("reload-form", props.meta.dn, [props.attr.name]);
|
||||
|
@ -332,7 +332,9 @@ async function load(
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("api/entry/" + dn);
|
||||
const response = await fetch("api/entry/" + dn, {
|
||||
headers: app?.authHeaders.value,
|
||||
});
|
||||
if (!response.ok) return;
|
||||
entry.value = (await response.json()) as Entry;
|
||||
|
||||
@ -344,7 +346,6 @@ async function load(
|
||||
}
|
||||
|
||||
function hasChanged(key: string) {
|
||||
console.log(entry.value?.changed);
|
||||
return (entry.value?.changed && entry.value.changed.includes(key)) || false;
|
||||
}
|
||||
|
||||
@ -360,6 +361,7 @@ async function save() {
|
||||
method: entry.value!.meta.isNew ? "PUT" : "POST",
|
||||
body: JSON.stringify(entry.value!.attrs),
|
||||
headers: {
|
||||
...app?.authHeaders.value,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
@ -384,6 +386,7 @@ async function renameEntry(rdn: string) {
|
||||
rdn: rdn,
|
||||
}),
|
||||
headers: {
|
||||
...app?.authHeaders.value,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
@ -394,7 +397,10 @@ async function renameEntry(rdn: string) {
|
||||
}
|
||||
|
||||
async function deleteEntry(dn: string) {
|
||||
const response = await fetch("api/entry/" + dn, { method: "DELETE" });
|
||||
const response = await fetch("api/entry/" + dn, {
|
||||
method: "DELETE",
|
||||
headers: app?.authHeaders.value,
|
||||
});
|
||||
if (response.ok && (await response.json()) !== undefined) {
|
||||
app?.showInfo("Deleted: " + dn);
|
||||
emit("update:activeDn", "-" + dn);
|
||||
@ -406,6 +412,7 @@ async function changePassword(oldPass: string, newPass: string) {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ old: oldPass, new1: newPass }),
|
||||
headers: {
|
||||
...app?.authHeaders.value,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
@ -421,7 +428,7 @@ async function changePassword(oldPass: string, newPass: string) {
|
||||
// Download LDIF
|
||||
async function ldif() {
|
||||
const response = await fetch("api/ldif/" + entry.value!.meta.dn, {
|
||||
headers: app?.authHeaders.value ?? {},
|
||||
headers: app?.authHeaders.value,
|
||||
});
|
||||
if (!response.ok) return;
|
||||
|
||||
|
@ -21,13 +21,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useEventListener, useMouseInElement } from "@vueuse/core";
|
||||
|
||||
const props = defineProps({ open: Boolean });
|
||||
const emit = defineEmits(["opened", "closed", "update:open"]);
|
||||
const items = ref<HTMLElement | null>(null);
|
||||
const selected = ref<number>();
|
||||
const { isOutside } = useMouseInElement(items);
|
||||
const isOutside = ref(false) //useMouseInElement(items);
|
||||
|
||||
function close() {
|
||||
selected.value = undefined;
|
||||
@ -70,8 +69,8 @@ function scroll(e: KeyboardEvent) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
useEventListener(document, "keydown", scroll);
|
||||
useEventListener(document, "click", close);
|
||||
//useEventListener(document, "keydown", scroll);
|
||||
//useEventListener(document, "click", close);
|
||||
});
|
||||
|
||||
watch(selected, (pos) => {
|
||||
|
19
src/main.ts
19
src/main.ts
@ -1,8 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./tailwind.css";
|
||||
import "@/assets/main.css";
|
||||
import "font-awesome/css/font-awesome.min.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
import App from "@/App.vue";
|
||||
import router from "@/router";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount("#app");
|
||||
|
57
src/router/index.ts
Normal file
57
src/router/index.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import MainView from "@/views/MainView.vue";
|
||||
import useAuthStore from "@/stores/auth";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "main",
|
||||
component: MainView,
|
||||
meta: {
|
||||
showFooter: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("@/views/LoginView.vue"),
|
||||
meta: {
|
||||
showFooter: true,
|
||||
guestOnly: true
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach(async (to, from) => {
|
||||
const auth = useAuthStore();
|
||||
|
||||
try {
|
||||
await auth.ready;
|
||||
} catch (error) {
|
||||
if (to.name == "login") return;
|
||||
|
||||
auth.savePath(to.fullPath);
|
||||
console.log("Saved path: ", auth.savedPath);
|
||||
|
||||
console.error(error);
|
||||
|
||||
return "/login";
|
||||
}
|
||||
|
||||
if (auth.token) {
|
||||
if (to.meta?.guestOnly) {
|
||||
return "/";
|
||||
}
|
||||
} else {
|
||||
if (!to.meta?.guest && to.name != "login") {
|
||||
return "/login";
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
export default router;
|
66
src/stores/auth.ts
Normal file
66
src/stores/auth.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { send } from "@/utils";
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const token = ref<string | null>(
|
||||
window.localStorage.getItem("_vue_ldap_ui_token")
|
||||
);
|
||||
const savedPath = ref<string | null>(null);
|
||||
|
||||
const ready = ref<Promise<boolean>>(readyGenerator());
|
||||
|
||||
async function readyGenerator(): Promise<boolean> {
|
||||
try {
|
||||
await whoami();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message == "No token")
|
||||
return Promise.reject(err);
|
||||
|
||||
if (err instanceof Response && err.status == 401) {
|
||||
token.value = null;
|
||||
window.localStorage.removeItem("_vue_ldap_ui_token");
|
||||
} else {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function whoami() {
|
||||
if (!token.value) return Promise.reject(new Error("No token"));
|
||||
|
||||
return await send<{}>("/api/whoami", null, "GET", {
|
||||
Authorization: "Bearer " + token.value,
|
||||
});
|
||||
}
|
||||
|
||||
async function login(userIdentifier: string, password: string) {
|
||||
const { token: loginToken } = await send<{ token: string }>(
|
||||
"/api/login",
|
||||
{ userIdentifier, password },
|
||||
"POST"
|
||||
);
|
||||
|
||||
token.value = loginToken;
|
||||
ready.value = readyGenerator();
|
||||
await ready.value;
|
||||
window.localStorage.setItem("_vue_ldap_ui_token", token.value);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async function savePath(value: string) {
|
||||
savedPath.value = value;
|
||||
|
||||
if (savedPath.value)
|
||||
window.localStorage.setItem("_vue_ldap_ui_savedPath", savedPath.value);
|
||||
else window.localStorage.removeItem("_vue_ldap_ui_savedPath");
|
||||
}
|
||||
|
||||
return { token, ready, login, savedPath, savePath };
|
||||
});
|
||||
|
||||
export default useAuthStore;
|
@ -1,21 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-front: 32 32 32;
|
||||
--color-back: 255 255 255;
|
||||
|
||||
--color-primary: 23 162 184;
|
||||
--color-secondary: 108 117 125;
|
||||
--color-danger: 229 57 53;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-front: 255 255 255;
|
||||
--color-back: 16 16 16;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
console.log("Rawr");
|
||||
import { useRouter } from "vue-router";
|
||||
import useAuthStore from "@/stores/auth";
|
||||
import { computed } from "@vue/reactivity";
|
||||
import { ref } from "vue";
|
||||
import Collapsible from "@/components/Collapsible.vue";
|
||||
import TailwindButton from "@/components/TailwindButton.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const userIdentifier = ref("");
|
||||
const password = ref("");
|
||||
const loggingIn = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
const disableButton = computed(
|
||||
() => loggingIn.value || !userIdentifier.value || !password.value
|
||||
);
|
||||
|
||||
async function login() {
|
||||
loggingIn.value = true;
|
||||
try {
|
||||
const result = await auth.login(userIdentifier.value, password.value);
|
||||
|
||||
console.log("Navigating to root!");
|
||||
const routerResult = await router.push("/");
|
||||
if (routerResult instanceof Error) throw routerResult;
|
||||
} catch (response: any) {
|
||||
if (response instanceof Error) {
|
||||
console.error(response);
|
||||
loggingIn.value = false;
|
||||
error.value = response.message;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(response instanceof Response)) return;
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
await new Promise<void>((resolve) => setTimeout(() => resolve(), 400));
|
||||
loggingIn.value = false;
|
||||
if (buffer.byteLength > 0) {
|
||||
const json = JSON.parse(new TextDecoder().decode(buffer));
|
||||
error.value = json.error ?? response.statusText;
|
||||
} else {
|
||||
error.value = `Error ${response.status}: ${response.statusText}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>Rawr!</div>
|
||||
<main>
|
||||
<form
|
||||
id="login-form"
|
||||
@submit.prevent="login"
|
||||
class="flex flex-col w-80"
|
||||
>
|
||||
<h2 class="mb-2 text-center text-2xl">LDAP Directory Login</h2>
|
||||
<hr class="mt-1 mb-2 border-zinc-800" />
|
||||
|
||||
<label class="flex flex-col mb-2">
|
||||
<span class="block mb-1 px-1" for="userIdentifier">
|
||||
Username
|
||||
</span>
|
||||
<input
|
||||
required
|
||||
v-model="userIdentifier"
|
||||
id="userIdentifier"
|
||||
class="bg-slate-700 px-2 mx-2 py-1 rounded border border-slate-600 caret-slate-400"
|
||||
form="login-form"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col mb-2">
|
||||
<span class="block mb-1 px-1" for="password"> Password </span>
|
||||
<input
|
||||
required
|
||||
v-model="password"
|
||||
id="password"
|
||||
class="bg-slate-700 px-2 mx-2 py-1 rounded border border-slate-600 caret-slate-400"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
form="login-form"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Collapsible class="" v-model="error" />
|
||||
<hr class="mt-2 mb-3 border-zinc-800" />
|
||||
<TailwindButton
|
||||
type="submit"
|
||||
:disabled="disableButton"
|
||||
class="mx-3 mb-3"
|
||||
>
|
||||
<template v-if="!loggingIn">Login</template>
|
||||
<div v-else>
|
||||
<span
|
||||
class="relative animate-pulse inline-flex rounded-full h-2 mx-1 w-2 bg-sky-500"
|
||||
></span>
|
||||
<span
|
||||
class="relative animate-pulse pulse-2 inline-flex rounded-full h-2 mx-1 w-2 bg-sky-500"
|
||||
></span>
|
||||
<span
|
||||
class="relative animate-pulse pulse-3 inline-flex rounded-full h-2 mx-1 w-2 bg-sky-500"
|
||||
></span>
|
||||
</div>
|
||||
</TailwindButton>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
205
src/views/MainView.vue
Normal file
205
src/views/MainView.vue
Normal file
@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, ref, watch } from "vue";
|
||||
import { LdapSchema } from "@/components/schema/schema";
|
||||
import type { Provided } from "@/data/provided";
|
||||
|
||||
import AttributeCard from "@/components/schema/AttributeCard.vue";
|
||||
import EntryEditor from "@/components/editor/EntryEditor.vue";
|
||||
import LdifImportDialog from "@/views/dialogs/LdifImportDialog.vue";
|
||||
import NavBar from "@/components/NavBar.vue";
|
||||
import ObjectClassCard from "@/components/schema/ObjectClassCard.vue";
|
||||
import TreeView from "@/components/TreeView.vue";
|
||||
import useAuthStore from "@/stores/auth";
|
||||
|
||||
interface Error {
|
||||
counter: number;
|
||||
cssClass: string;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
// Alerts
|
||||
const error = ref<Error>(); // status alert
|
||||
|
||||
// Display an info popup
|
||||
function showInfo(msg: string) {
|
||||
error.value = { counter: 5, cssClass: "bg-emerald-300", msg: "" + msg };
|
||||
setTimeout(() => {
|
||||
error.value = undefined;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Flash a warning popup
|
||||
function showWarning(msg: string) {
|
||||
error.value = { counter: 10, cssClass: "bg-amber-200", msg: "⚠️ " + msg };
|
||||
setTimeout(() => {
|
||||
error.value = undefined;
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Report an error
|
||||
function showError(msg: string) {
|
||||
error.value = { counter: 60, cssClass: "bg-red-300", msg: "⛔ " + msg };
|
||||
setTimeout(() => {
|
||||
error.value = undefined;
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
function showException(msg: string) {
|
||||
const span = document.createElement("span");
|
||||
span.innerHTML = msg.replace("\n", " ");
|
||||
const titles = span.getElementsByTagName("title");
|
||||
for (let i = 0; i < titles.length; ++i) {
|
||||
span.removeChild(titles[i]);
|
||||
}
|
||||
let text = "";
|
||||
const headlines = span.getElementsByTagName("h1");
|
||||
for (let i = 0; i < headlines.length; ++i) {
|
||||
text = text + headlines[i].textContent + ": ";
|
||||
span.removeChild(headlines[i]);
|
||||
}
|
||||
showError(text + " " + span.textContent);
|
||||
}
|
||||
|
||||
// Authentication
|
||||
const auth = useAuthStore();
|
||||
const user = ref<string>(); // logged in user
|
||||
const baseDn = ref<string>();
|
||||
const authHeaders = ref<Record<string, string>>({
|
||||
Authorization: "Bearer " + auth.token,
|
||||
});
|
||||
|
||||
// Components
|
||||
const treeOpen = ref(true); // Is the tree visible?
|
||||
const activeDn = ref<string>(); // currently active DN in the editor
|
||||
const modal = ref<string>(); // modal popup
|
||||
|
||||
// LDAP schema
|
||||
const schema = ref<LdapSchema>();
|
||||
const oc = ref<string>(); // objectClass info in side panel
|
||||
const attr = ref<string>(); // attribute info in side panel
|
||||
|
||||
// Helpers for components
|
||||
const provided: Provided = {
|
||||
get schema() {
|
||||
return schema.value;
|
||||
},
|
||||
showInfo,
|
||||
showException,
|
||||
showWarning,
|
||||
authHeaders,
|
||||
};
|
||||
|
||||
provide("app", provided);
|
||||
|
||||
onMounted(async () => {
|
||||
// Runs on page load
|
||||
// Get the DN of the current user
|
||||
const whoamiResponse = await fetch("api/whoami", {
|
||||
headers: provided.authHeaders.value,
|
||||
});
|
||||
if (whoamiResponse.ok) {
|
||||
user.value = await whoamiResponse.json();
|
||||
}
|
||||
|
||||
// Load the schema
|
||||
const schemaResponse = await fetch("api/schema", {
|
||||
headers: provided.authHeaders.value,
|
||||
});
|
||||
if (schemaResponse.ok) {
|
||||
schema.value = new LdapSchema(await schemaResponse.json());
|
||||
}
|
||||
});
|
||||
|
||||
watch(attr, (a) => {
|
||||
if (a) oc.value = undefined;
|
||||
});
|
||||
|
||||
watch(oc, (o) => {
|
||||
if (o) attr.value = undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-screen h-screen">
|
||||
<NavBar
|
||||
v-model:treeOpen="treeOpen"
|
||||
:dn="activeDn"
|
||||
:base-dn="baseDn"
|
||||
:user="user"
|
||||
@show-modal="modal = $event"
|
||||
@select-dn="activeDn = $event"
|
||||
@show-oc="oc = $event"
|
||||
/>
|
||||
|
||||
<LdifImportDialog v-model:modal="modal" @ok="activeDn = '-'" />
|
||||
|
||||
<div class="flex container">
|
||||
<div class="space-y-4">
|
||||
<!-- left column -->
|
||||
<TreeView
|
||||
v-model:activeDn="activeDn"
|
||||
v-show="treeOpen"
|
||||
@base-dn="baseDn = $event"
|
||||
/>
|
||||
<ObjectClassCard
|
||||
v-model="oc"
|
||||
@show-attr="attr = $event"
|
||||
@show-oc="oc = $event"
|
||||
/>
|
||||
<AttributeCard v-model="attr" @show-attr="attr = $event" />
|
||||
</div>
|
||||
|
||||
<div class="flex-auto mt-4">
|
||||
<!-- main editing area -->
|
||||
<Transition name="fade"
|
||||
><!-- Notifications -->
|
||||
<div
|
||||
v-if="error"
|
||||
:class="error.cssClass"
|
||||
class="rounded mx-4 mb-4 p-3 border border-front/70 text-front/70 dark:text-back/70"
|
||||
>
|
||||
{{ error.msg }}
|
||||
<span
|
||||
class="float-right control"
|
||||
@click="error = undefined"
|
||||
>✖</span
|
||||
>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<EntryEditor
|
||||
v-model:activeDn="activeDn"
|
||||
:user="user"
|
||||
@show-attr="attr = $event"
|
||||
@show-oc="oc = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.control {
|
||||
@apply opacity-70 hover:opacity-90 cursor-pointer select-none leading-none pt-1 pr-1;
|
||||
}
|
||||
|
||||
button,
|
||||
.btn,
|
||||
[type="button"] {
|
||||
@apply px-3 py-2 rounded text-back dark:text-front font-medium outline-none;
|
||||
}
|
||||
|
||||
button.btn {
|
||||
@apply border-solid border-back border-2 focus:border-primary dark:focus:border-front;
|
||||
}
|
||||
|
||||
select {
|
||||
background: url("assets/gray_bg.svg") no-repeat right;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
.glyph {
|
||||
font-family: sans-serif, FontAwesome;
|
||||
font-style: normal;
|
||||
}
|
||||
</style>
|
@ -18,8 +18,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { inject, ref } from "vue";
|
||||
import Modal from "@/components/ui/Modal.vue";
|
||||
import type { Provided } from "@/data/provided";
|
||||
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
@ -33,6 +34,7 @@ const props = defineProps({
|
||||
});
|
||||
const upload = ref<HTMLInputElement | null>(null);
|
||||
const emit = defineEmits(["ok", "update:modal"]);
|
||||
const app = inject<Provided>("app");
|
||||
|
||||
// add an image
|
||||
async function onOk(evt: Event) {
|
||||
@ -44,6 +46,7 @@ async function onOk(evt: Event) {
|
||||
const response = await fetch("api/blob/" + props.attr + "/0/" + props.dn, {
|
||||
method: "PUT",
|
||||
body: fd,
|
||||
headers: app?.authHeaders.value,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
|
@ -31,10 +31,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { inject, ref } from "vue";
|
||||
import Modal from "@/components/ui/Modal.vue";
|
||||
import NodeLabel from "@/components/NodeLabel.vue";
|
||||
import type { TreeNode } from "@/data/treeNode";
|
||||
import type { Provided } from "@/data/provided";
|
||||
|
||||
const props = defineProps({
|
||||
dn: { type: String, required: true },
|
||||
@ -43,10 +44,13 @@ const props = defineProps({
|
||||
});
|
||||
const subtree = ref<TreeNode[]>([]);
|
||||
const emit = defineEmits(["ok", "update:modal"]);
|
||||
const app = inject<Provided>("app");
|
||||
|
||||
// List subordinate elements to be deleted
|
||||
async function init() {
|
||||
const response = await fetch("api/subtree/" + props.dn);
|
||||
const response = await fetch("api/subtree/" + props.dn, {
|
||||
headers: app?.authHeaders.value,
|
||||
});
|
||||
subtree.value = (await response.json()) as TreeNode[];
|
||||
}
|
||||
|
||||
|
@ -53,6 +53,7 @@ async function onOk() {
|
||||
const response = await fetch("api/ldif", {
|
||||
method: "POST",
|
||||
body: ldifData.value,
|
||||
headers: app?.authHeaders.value,
|
||||
});
|
||||
if (response.ok) emit("ok");
|
||||
}
|
||||
|
@ -104,7 +104,7 @@ async function check() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ check: oldPassword.value }),
|
||||
headers: {
|
||||
...(app?.authHeaders.value ?? {}),
|
||||
...app?.authHeaders.value,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
@ -1,11 +1,10 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import viteCompression from "vite-plugin-compression";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), viteCompression()],
|
||||
plugins: [vue()],
|
||||
|
||||
build: {
|
||||
manifest: true,
|
||||
|
Loading…
Reference in New Issue
Block a user