Compare commits

...

3 Commits

Author SHA1 Message Date
b87b1dcfd0 Enhancements to logic that makes form.select component
All checks were successful
Create Docker Image / Test Application (x86_64) (push) Successful in 30s
Create Docker Image / Build Docker Image (x86_64) (push) Successful in 1m34s
Create Docker Image / Build Docker Image (arm64) (push) Successful in 2m49s
Create Docker Image / Final Docker Image Manifest (push) Successful in 10s
2025-06-11 19:33:36 +09:30
d61ecc96db Enable creation of new entries via templates 2025-06-11 19:28:38 +09:30
820f398c2c Start of work on templates - identify templates that apply to existing entries 2025-06-10 16:02:07 +10:00
19 changed files with 408 additions and 66 deletions

51
app/Classes/Template.php Normal file
View File

@ -0,0 +1,51 @@
<?php
namespace App\Classes;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;
class Template
{
private string $file;
private array $template;
private(set) bool $invalid = FALSE;
private string $reason = '';
public function __construct(string $file)
{
$td = Storage::disk(config('pla.template.dir'));
$this->file = $file;
try {
$this->template = json_decode($td->get($file),null,512,JSON_OBJECT_AS_ARRAY|JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$this->invalid = TRUE;
$this->reason = $e->getMessage();
}
}
public function __get(string $key): mixed
{
return match ($key) {
'attributes' => collect(array_map('strtolower',array_keys(Arr::get($this->template,$key)))),
'objectclasses' => collect(array_map('strtolower',Arr::get($this->template,$key))),
'enabled' => Arr::get($this->template,$key,FALSE) && (! $this->invalid),
'icon','regexp','title' => Arr::get($this->template,$key),
default => throw new \Exception('Unknown key: '.$key),
};
}
public function __isset(string $key): bool
{
return array_key_exists($key,$this->template);
}
public function __toString(): string
{
return $this->invalid ? '' : Arr::get($this->template,'title','No Template Name');
}
}

View File

@ -55,16 +55,24 @@ class HomeController extends Controller
$key = $this->request_key($request,collect(old()));
$template = NULL;
$o = new Entry;
$o->setRDNBase($key['dn']);
if (count($x=array_filter(old('objectclass',$request->objectclass)))) {
$o->objectclass = $x;
if (count($x=collect(old('objectclass',$request->validated('objectclass')))->dot()->filter())) {
$o->objectclass = Arr::undot($x);
// Also add in our required attributes
foreach($o->getAvailableAttributes()->filter(fn($item)=>$item->required) as $ao)
$o->{$ao->name} = [Entry::TAG_NOTAG=>''];
$o->setRDNBase($key['dn']);
} elseif ($request->validated('template')) {
$template = $o->template($request->validated('template'));
$o->objectclass = [Entry::TAG_NOTAG=>$template->objectclasses->toArray()];
// @todo We need to add aliases
foreach($o->getAvailableAttributes()->filter(fn($item)=>$template->attributes->contains($item)) as $ao)
$o->{$ao->name} = [Entry::TAG_NOTAG=>''];
}
$step = $request->step ? $request->step+1 : old('step');
@ -74,6 +82,7 @@ class HomeController extends Controller
->with('bases',$this->bases())
->with('o',$o)
->with('step',$step)
->with('template',$template)
->with('container',old('container',$key['dn']));
}
@ -383,7 +392,12 @@ class HomeController extends Controller
->with('bases',$this->bases());
// If we are rendering a DN, rebuild our object
if ($key['dn']) {
if ($key['cmd'] === 'create') {
$o = new Entry;
$o->setRDNBase($key['dn']);
} elseif ($key['dn']) {
// @todo Need to handle if DN is null, for example if the user's session expired and the ACLs dont let them retrieve $key['dn']
$o = config('server')->fetch($key['dn']);
foreach (collect(old())->except(['key','dn','step','_token','userpassword_hash','rdn','rdn_value']) as $attr => $value)
@ -393,6 +407,7 @@ class HomeController extends Controller
return match ($key['cmd']) {
'create' => $view
->with('container',old('container',$key['dn']))
->with('o',$o)
->with('step',1),
'dn' => $view

View File

@ -66,12 +66,43 @@ class EntryAddRequest extends FormRequest
'min:1',
'max:1',
],
'objectclass._null_'=>[
'required',
'objectclass._null_' => [
function (string $attribute,mixed $value,\Closure $fail) {
$oc = collect($value)->dot()->filter();
// If this is step 1 and there is no objectclass, and no template, then fail
if ((! $oc->count())
&& (request()->post('step') == 1)
&& (! request()->post('template')))
{
$fail(__('Select an objectclass or a template'));
}
// Cant have both an objectclass and a template
if (request()->post('template') && $oc->count())
$fail(__('You cannot select a template and an objectclass'));
},
'array',
'min:1',
new HasStructuralObjectClass,
]
],
'template' => [
function (string $attribute,mixed $value,\Closure $fail) {
$oc = collect(request()->post('objectclass'))->dot()->filter();
// If this is step 1 and there is no objectclass, and no template, then fail
if ((! collect($value)->filter()->count())
&& (request()->post('step') == 1)
&& (! $oc->count()))
{
$fail(__('Select an objectclass or a template'));
}
// Cant have both an objectclass and a template
if ($oc->count() && strlen($value))
$fail(__('You cannot select a template and an objectclass'));
},
],
])
->toArray();
}

View File

@ -4,9 +4,12 @@ namespace App\Ldap;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use LdapRecord\Support\Arr;
use LdapRecord\Models\Model;
use App\Classes\Template;
use App\Classes\LDAP\Attribute;
use App\Classes\LDAP\Attribute\Factory;
use App\Classes\LDAP\Export\LDIF;
@ -39,9 +42,19 @@ class Entry extends Model
public function __construct(array $attributes = [])
{
$this->objects = collect();
$this->templates = collect(['default'=>__('LDAP Entry')]);
parent::__construct($attributes);
// Load any templates
$x = Storage::disk(config('pla.template.dir'));
$this->templates = collect();
foreach (array_filter($x->files(),fn($item)=>Str::endsWith($item,'.json')) as $file)
$this->templates->put($file,new Template($file));
$this->templates = $this->templates
->filter(fn($item)=>(! $item->invalid) && $item->enabled)
->sortBy(fn($item)=>$item);
}
public function discardChanges(): static
@ -131,6 +144,12 @@ class Entry extends Model
$this->objects = collect();
}
// Filter out our templates specific for this entry
if ($this->dn && (! in_array(strtolower($this->dn),['cn=subschema']))) {
$this->templates = $this->templates
->filter(fn($item)=>! count($item->objectclasses->diff(array_map('strtolower',Arr::get($this->attributes,'objectclass')))));
}
return $this;
}
@ -530,5 +549,13 @@ class Entry extends Model
throw new InvalidUsage('Cannot set RDN base on existing entries');
$this->rdnbase = $bdn;
$this->templates = $this->templates
->filter(fn($item)=>(! $item->regexp) || preg_match($item->regexp,$bdn));
}
public function template(string $item): Template|Null
{
return Arr::get($this->templates,$item);
}
}

View File

@ -24,6 +24,7 @@ class HasStructuralObjectClass implements ValidationRule
if ($item && config('server')->schema('objectclasses',$item)->isStructural())
return;
$fail('There isnt a Structural Objectclass.');
if (collect($value)->dot()->filter()->count())
$fail(__('There isnt a Structural Objectclass.'));
}
}

10
config/filesystems.php Normal file
View File

@ -0,0 +1,10 @@
<?php
return [
'disks' => [
'templates' => [
'driver' => 'local',
'root' => base_path(env('LDAP_TEMPLATE_DIR','templates')),
],
],
];

View File

@ -76,4 +76,8 @@ return [
'attr' => [env('LDAP_LOGIN_ATTR','uid') => env('LDAP_LOGIN_ATTR_DESC','User ID')], // Attribute used to find user for login
'objectclass' => explode(',',env('LDAP_LOGIN_OBJECTCLASS', 'posixAccount')), // Objectclass that users must contain to login
],
'template' => [
'dir' => env('LDAP_TEMPLATE_DRIVER','templates'),
],
];

View File

@ -38,7 +38,7 @@
</div>
<div class="modal-body">
<x-form.select id="newoc" label="Select from..."/>
<x-form.select id="newoc" name="newoc" :label="__('Select from').'...'"/>
</div>
<div class="modal-footer">

View File

@ -1,4 +1,4 @@
<button id="form-reset" class="btn btn-outline-danger">@lang('Reset')</button>
<button id="form-reset" class="btn btn-sm btn-outline-danger">@lang('Reset')</button>
@section('page-scripts')
<script>

View File

@ -1,28 +1,32 @@
<x-form.base {{ $attributes }}>
@isset($name)
<input type="hidden" id="{{ $id ?? $name }}_disabled" name="{{ $name }}" value="" disabled>
@else
@php(throw new \Exception('here'))
@dd('no name',$id)
@endisset
<select class="form-select @isset($name)@error((! empty($old)) ? $old : ($id ?? $name)) is-invalid @enderror @endisset" id="{{ $id ?? $name }}" @isset($name)name="{{ $name }}"@endisset @required(isset($required) && $required) @disabled(isset($disabled) && $disabled)>
@if((empty($value) && ! empty($options)) || isset($addnew) || isset($choose))
<select class="form-select @error($old ?? ($id ?? $name)) is-invalid @enderror" id="{{ $id ?? $name }}" name="{{ $name }}" @required($required ?? FALSE) @disabled($disabled ?? FALSE)>
@if((empty($value) && ! empty($options)) || isset($addnew))
<option value=""></option>
@isset($addnew)
<option value="new">{{ $addnew ?: 'Add New' }}</option>
@endisset
@endif
@isset($options)
@empty($groupby)
@foreach($options as $option)
@continue(! Arr::get($option,'value'))
<option value="{{ Arr::get($option,'id') }}" @selected(isset($name) && (Arr::get($option,'id') == old($old ?? $name,$value ?? '')))>{{ Arr::get($option,'value') }}</option>
<option value="{{ Arr::get($option,'id') }}" @selected(Arr::get($option,'id') == collect(old())->dot()->get(isset($old) ? $old.'.0' : ($id ?? $name)))>{{ Arr::get($option,'value') }}</option>
@endforeach
@else
@foreach($options->groupBy($groupby) as $group)
<optgroup label="{{ $groupby == 'active' ? (Arr::get($group->first(),$groupby) ? 'Active' : 'Not Active') : Arr::get($group->first(),$groupby) }}">
<optgroup label="{{ Arr::get($group->first(),$groupby) }}">
@foreach($group as $option)
@continue(! Arr::get($option,'value'))
<option value="{{ Arr::get($option,'id') }}" @selected(isset($name) && (Arr::get($option,'id') == old($old ?? $name,$value ?? '')))>{{ Arr::get($option,'value') }}</option>
<option value="{{ Arr::get($option,'id') }}" @selected(Arr::get($option,'id') == collect(old())->dot()->get(isset($old) ? $old.'.0' : ($id ?? $name)))>{{ Arr::get($option,'value') }}</option>
@endforeach
</optgroup>
@endforeach

View File

@ -14,7 +14,7 @@
<div class="row">
<div class="col-12 pt-2">
<x-form.select id="newattr" :label="__('Select from').'...'" :options="$o->getMissingAttributes()->sortBy('name')->unique('name')->map(fn($item)=>['id'=>$item->name,'value'=>$item->name])"/>
<x-form.select name="newattr" :label="__('Select from').'...'" :options="$o->getMissingAttributes()->sortBy('name')->unique('name')->map(fn($item)=>['id'=>$item->name,'value'=>$item->name])"/>
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
<!-- $template=Template -->
<form id="template-edit" method="POST" class="needs-validation" action="{{ url('entry/update/pending') }}" novalidate readonly>
@csrf
<input type="hidden" name="dn" value="">
<div class="card-body">
<div class="tab-content">
@php($up=(session()->pull('updated') ?: collect()))
@php($attributes=$o->template($template)?->attributes)
@foreach($o->getVisibleAttributes()->filter(fn($item)=>$attributes->contains($item)) as $ao)
<x-attribute-type :o="$ao" :edit="TRUE" :new="FALSE" :updated="$up->contains($ao->name_lc)"/>
@endforeach
</div>
</div>
</form>

View File

@ -17,7 +17,7 @@
<div class="main-card mb-3 card">
<div class="card-header">
@lang('Create New Entry') - @lang('Step') {{ $step }}
@lang('Create New Entry') - @lang('Step') {{ $step }} @isset($template) <span class="ms-auto"><i class="fa fa-fw {{ $template->icon }}"></i> {{ $template->title }}</span>@endisset
</div>
<div class="card-body">
@ -30,19 +30,35 @@
@switch($step)
@case(1)
<div class="row">
<div class="col-12 col-md-6">
<div class="col-12 col-md-5">
<x-form.select
id="objectclass"
name="objectclass[{{ Entry::TAG_NOTAG }}][]"
old="objectclass.{{ Entry::TAG_NOTAG }}"
:label="__('Select a Structural ObjectClass...')"
:label="__('Select a Structural ObjectClass').'...'"
:options="($oc=$server->schema('objectclasses'))
->filter(fn($item)=>$item->isStructural())
->sortBy(fn($item)=>$item->name_lc)
->map(fn($item)=>['id'=>$item->name,'value'=>$item->name])"
multiple="false"
:allowclear="TRUE"
/>
</div>
@if($o->templates->count())
<div class="col-md-1">
<strong>@lang('OR')</strong>
</div>
<div class="col-12 col-md-5">
<x-form.select
name="template"
:label="__('Select a Template').'...'"
:options="$o->templates
->map(fn($item,$key)=>['id'=>$key,'value'=>$item->title])"
:allowclear="TRUE"
/>
</div>
@endif
</div>
@break
@ -53,14 +69,12 @@
<x-attribute-type :o="$ao" :edit="TRUE" :new="FALSE" :updated="FALSE"/>
@endforeach
@include('fragment.dn.add_attr')
@break;
@endswitch
</form>
<div class="row d-none pt-3">
<div class="col-12 {{ $step > 1 ? 'offset-sm-2' : '' }} col-lg-10">
<div class="col-11 {{ $step > 1 ? 'text-end' : '' }} pe-0">
<x-form.reset form="dn-create"/>
<x-form.submit :action="__('Next')" form="dn-create"/>
</div>
@ -102,7 +116,15 @@
}
$(document).ready(function() {
@if($step === 2)
@if($step === 1)
$('#objectclass').on('select2:open',function(){
$('#template').val(null).trigger('change');
});
$('#template').on('select2:open',function(){
$('#objectclass').val(null).trigger('change');
})
@elseif($step === 2)
$('#newattr').on('change',function(item) {
var oc = $('attribute#objectclass input[type=text]')
.map((key,item)=>{return $(item).val()}).toArray();

View File

@ -74,53 +74,49 @@
<div class="col-12">
<div class="d-flex justify-content-center">
<div role="group" class="btn-group btn-group-sm nav pb-3">
<!-- It is assumed that the entry has atleast 1 template "default" -->
@if($o->templates->count() > 1)
@foreach($o->templates as $template => $name)
<span data-bs-toggle="tab" href="#template-{{$template}}" @class(['btn','btn-outline-focus','active'=>$loop->index === 0])>{{ $name }}</span>
@endforeach
<!-- If we have templates that cover this entry -->
@foreach($o->templates as $template => $name)
<span data-bs-toggle="tab" href="#template-{{$template}}" @class(['btn','btn-outline-focus','active'=>$loop->index === 0])><i class="fa fa-fw pe-2 {{ $o->template($template)->icon }}"></i> {{ $name }}</span>
@endforeach
@if($o->templates->count())
<span data-bs-toggle="tab" href="#template-default" @class(['btn','btn-outline-focus','p-1','active'=>(! $o->templates->count())])>{{ __('LDAP Entry') }}</span>
@endif
</div>
</div>
<div class="tab-content">
@foreach($o->templates as $template => $name)
@switch($template)
@case('default')
<div @class(['tab-pane','active'=>$loop->index === 0]) id="template-{{$template}}" role="tabpanel">
<form id="dn-edit" method="POST" class="needs-validation" action="{{ url('entry/update/pending') }}" novalidate readonly>
@csrf
<input type="hidden" name="dn" value="">
<div class="card-body">
<div class="tab-content">
@php($up=(session()->pull('updated') ?: collect()))
@foreach($o->getVisibleAttributes() as $ao)
<x-attribute-type :o="$ao" :edit="TRUE" :new="FALSE" :updated="$up->contains($ao->name_lc)"/>
@endforeach
@include('fragment.dn.add_attr')
</div>
</div>
</form>
</div>
@break
@default
<div @class(['tab-pane','active'=>$loop->index === 0]) id="template-{{$template}}" role="tabpanel">
<p>{{$name}}</p>
</div>
@endswitch
<div @class(['tab-pane','active'=>$loop->index === 0]) id="template-{{$template}}" role="tabpanel">
@include('fragment.template.dn',['template'=>$template])
</div>
@endforeach
</div>
</div>
</div>
<div class="row d-none pt-3">
<div class="col-12 offset-sm-2 col-sm-4 col-lg-2">
<x-form.reset form="dn-edit"/>
<x-form.submit :action="__('Update')" form="dn-edit"/>
<div @class(['tab-pane','active'=>(! $o->templates->count())]) id="template-default" role="tabpanel">
<form id="dn-edit" method="POST" class="needs-validation" action="{{ url('entry/update/pending') }}" novalidate readonly>
@csrf
<input type="hidden" name="dn" value="">
<div class="card-body">
<div class="tab-content">
@php($up=(session()->pull('updated') ?: collect()))
@foreach($o->getVisibleAttributes() as $ao)
<x-attribute-type :o="$ao" :edit="TRUE" :new="FALSE" :updated="$up->contains($ao->name_lc)"/>
@endforeach
@include('fragment.dn.add_attr')
</div>
</div>
</form>
<div class="row d-none pt-3">
<div class="col-11 text-end">
<x-form.reset form="dn-edit"/>
<x-form.submit :action="__('Update')" form="dn-edit"/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -162,7 +158,6 @@
// Find all input items and turn off readonly
$('input.form-control').each(function() {
// Except for objectClass - @todo show an "X" instead
if ($(this)[0].name.match(/^objectclass/))
return;

18
templates/dns_domain.json Normal file
View File

@ -0,0 +1,18 @@
{
"title": "Generic: DNS Entry",
"description": "New DNS Entry",
"enabled": true,
"icon": "fa-globe",
"rdn": "dc",
"objectclasses": [
"dnsDomain"
],
"attributes": {
"dc": {
"display": "Domain Component",
"order": 1
}
}
}

25
templates/example.json Normal file
View File

@ -0,0 +1,25 @@
{
"title": "Example entry",
"description": "This is the description",
"enabled": false,
"icon": "fa-star-of-life",
"rdn": "o",
"regexp": "/^$/",
"objectclasses": [
"organization"
],
"attributes": {
"attribute1": {
"display": "Attribute 1",
"hint": "This is an example",
"order": 1
},
"attribute2": {
"display": "Attribute 2",
"hint": "This is an example",
"order": 2
}
}
}

20
templates/o.json Normal file
View File

@ -0,0 +1,20 @@
{
"title": "Generic: Organisational",
"description": "New Organisational",
"enabled": true,
"icon": "fa-building",
"rdn": "ou",
"regexp": "/^c=.+,?/",
"objectclasses": [
"organization"
],
"attributes": {
"o": {
"display": "Organisation",
"hint": "This is an example",
"order": 1
}
}
}

20
templates/ou.json Normal file
View File

@ -0,0 +1,20 @@
{
"title": "Generic: Organisational Unit",
"description": "New Organisational Unit",
"enabled": true,
"icon": "fa-layer-group",
"rdn": "ou",
"regexp": "/^o=.+,?/",
"objectclasses": [
"organizationalUnit"
],
"attributes": {
"ou": {
"display": "Organisational Unit",
"hint": "This is an example",
"order": 1
}
}
}

View File

@ -0,0 +1,82 @@
{
"title": "Generic: User Account",
"description": "New User Account",
"enabled": true,
"icon": "fa-user",
"rdn": "cn",
"regexp": "/^ou=.+,o=.+,?/",
"objectclasses": [
"inetOrgPerson",
"posixAccount"
],
"attributes": {
"givenName": {
"display": "First Name",
"onchange": [
"=autoFill(cn;%givenName% %sn%)",
"=autoFill(uid;%givenName|0-1/l%%sn/l%)"
],
"order": 1
},
"sn": {
"display": "Last Name",
"onchange": [
"=autoFill(cn;%givenName% %sn%)",
"=autoFill(uid;%givenName|0-1/l%%sn/l%)"
],
"order": 2
},
"cn": {
"display": "Common Name",
"readonly": true,
"order": 3
},
"uid": {
"display": "User ID",
"onchange": [
"=autoFill(homeDirectory;/home/users/%uid%)"
],
"order": 4
},
"userPassword": {
"display": "Password",
"order": 5
},
"uidNumber": {
"display": "UID Number",
"readonly": true,
"value": "=php.GetNextNumber(/;uidNumber)",
"order": 6
},
"gidNumber": {
"display": "UID Number",
"readonly": true,
"onchange": [
"=autoFill(homeDirectory;/home/users/%gidNumber|0-0/T%/%uid|3-%)"
],
"value": "=php.GetNextNumber(/;uidNumber)",
"value": "=php.PickList(/;(&(objectClass=posixGroup));gidNumber;%cn%;;;;cn)",
"order": 7
},
"homeDirectory": {
"display": "Home Directory",
"order": 8
},
"loginShell": {
"display": "Login Shell",
"select": {
"/bin/bash": "Bash",
"/bin/csh": "C Shell",
"/bin/dash": "Dash",
"/bin/sh": "Shell",
"/bin/tsh": "Turbo C Shell",
"/bin/zsh": "ZSH",
"/bin/false": "False",
"/usr/sbin/nologin": "No Login"
},
"order": 9
}
}
}