ADconsole v1.0.0
CI / PHP 8.2 (push) Failing after 2m46s
CI / PHP 8.3 (push) Failing after 59s
CI / PHP 8.4 (push) Failing after 1m7s

First public release.

- docs: document the audit log, user offboarding, CSV export and the
  "require a reason for destructive actions" setting across README, docs/
  and CLAUDE/AGENTS.md; note retention for panel_audit
- docs: fill the publishing placeholders (repo URL, security contact,
  issue-template links) and add a semantic-versioning policy
- move agent/contributor context into CLAUDE/ (AGENTS.md + a README);
  root CLAUDE.md is now a thin @-import pointer
- add a favicon (shield-lock, matching the login screen) to the login
  page and the app layout
- CHANGELOG: promote [Unreleased] to [1.0.0]; keep the earlier internal
  build as [0.1.0]

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfuvfwexztdS7LDpGuKsuK
This commit is contained in:
kzbkowskiandClaude Sonnet 5 committed 2026-09-08 23:14:39 +02:00
commit 1126d96271
174 files changed
+27113

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# Build-context trimming for `docker build` (docker/php/Dockerfile).
# The compose stack bind-mounts the project at runtime, so the image only needs
# the entrypoint; keep the context small.
.git
.github
.gitattributes
.gitignore
.editorconfig
vendor
node_modules
public/build
public/hot
public/storage
storage/app/*
!storage/app/.gitignore
!storage/app/private
!storage/app/public
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/testing/*
storage/framework/views/*
storage/logs/*
bootstrap/cache/*
!bootstrap/cache/.gitignore
.env
.env.*
!.env.example
docs
tests
phpunit.xml
.phpunit.result.cache
.phpunit.cache
*.md
docker/compose.override.yaml
docker/compose.override.yml
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[{compose,docker-compose}.{yml,yaml}]
indent_size = 4
+124
View File
@@ -0,0 +1,124 @@
# =============================================================================
# ADconsole - example environment file
#
# Copy to .env and fill in the values for your environment:
# cp .env.example .env && php artisan key:generate
#
# .env is git-ignored - never commit real hosts, service-account DNs or
# passwords. Every value below is a placeholder.
# =============================================================================
# --- Application -------------------------------------------------------------
APP_NAME="ADconsole"
# Use "local" only on a developer machine (enables verbose errors / debugbar).
APP_ENV=production
APP_KEY=
APP_DEBUG=false
# Public URL the panel is served from (used for absolute links and cookies).
APP_URL=https://adconsole.example.com
# Hostname Traefik routes to the container (docker/compose.yaml). Ignored on a
# bare-metal install.
APP_DOMAIN=adconsole.example.com
# Interface language: pl | en. FALLBACK is used for missing translation keys.
APP_LOCALE=pl
APP_FALLBACK_LOCALE=en
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
# --- Logging ---------------------------------------------------------------
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
# debug | info | notice | warning | error ...
LOG_LEVEL=warning
# --- Database -------------------------------------------------------------
# ADconsole keeps NO Active Directory data in SQL. The database only backs the
# application layer: sessions, cache, queue and the panel configuration
# (table `panel_settings`). Any Laravel-supported driver works; MariaDB/MySQL
# is the default and what docker/compose.yaml provisions.
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=adconsole
DB_USERNAME=adconsole
DB_PASSWORD=
# Only read by docker/compose.yaml when it first initialises the MariaDB
# volume. Changing them later does NOT change an existing database.
DB_ROOT_PASSWORD=
# --- Session / cache / queue --------------------------------------------
# "file" keeps sessions on local disk (fine for a single node). Use "database"
# or "redis" when running more than one app container behind a load balancer.
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
CACHE_STORE=database
QUEUE_CONNECTION=database
# --- Active Directory: connection -------------------------------------------
# Service account used ONLY to read/modify directory objects. Panel admins log
# in with their own domain account (see AD_LOGIN_GROUP); this account is never
# used for interactive login.
LDAP_CONNECTION=default
LDAP_LOGGING=true
# One or more domain controllers (comma-separated, tried in order).
LDAP_HOSTS=dc01.corp.example.com,dc02.corp.example.com
LDAP_USERNAME="CN=svc-adconsole,OU=Service Accounts,DC=corp,DC=example,DC=com"
LDAP_PASSWORD=
# 636 = LDAPS, 389 = plain / STARTTLS.
LDAP_PORT=636
LDAP_TIMEOUT=5
# Transport security. Password reset and every other WRITE to AD REQUIRE an
# encrypted channel - AD rejects unicodePwd over a clear connection.
# LDAP_SSL=true -> LDAPS (port 636) [recommended]
# LDAP_TLS=true -> STARTTLS (port 389)
LDAP_SSL=true
LDAP_TLS=false
# Verify the DC certificate. Set false for a self-signed / internal-CA cert
# that the app server does not trust.
LDAP_TLS_VERIFY=false
# Base DN for ALL modules. It MUST contain the users, computers AND groups
# branches - if those OUs are siblings, point this at the domain root, or the
# computer/group lists and their OU trees come back empty.
LDAP_BASE_DN="DC=corp,DC=example,DC=com"
# --- Active Directory: defaults ------------------------------------------
# OU new objects are created in when no branch is picked in the UI; also caps
# the OU tree shown in each module.
LDAP_DEFAULT_USER_OU="OU=Users,DC=corp,DC=example,DC=com"
LDAP_DEFAULT_COMPUTER_OU="OU=Computers,DC=corp,DC=example,DC=com"
LDAP_DEFAULT_GROUPS_OU="OU=Groups,DC=corp,DC=example,DC=com"
# Domain name shown in the panel header.
AD_DOMAIN_LABEL=corp.example.com
# --- Active Directory: who may sign in ---------------------------------------
# Members of this group may log into the panel. Full group DN (recommended) or
# a bare CN. Nested membership and the primary group are honoured. Empty = no
# one can log in.
AD_LOGIN_GROUP="CN=ADconsole Admins,OU=Groups,DC=corp,DC=example,DC=com"
# --- Mail (optional) -----------------------------------------------------
# ADconsole does not send mail out of the box; configure only if you add it.
MAIL_MAILER=log
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="adconsole@example.com"
MAIL_FROM_NAME="${APP_NAME}"
# --- Front-end build ---------------------------------------------------------
VITE_APP_NAME="${APP_NAME}"
+15
View File
@@ -0,0 +1,15 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
*.sh text eol=lf
# Kept out of `git archive` tarballs (does not affect clones).
/.github export-ignore
/.editorconfig export-ignore
/.styleci.yml export-ignore
/phpunit.xml export-ignore
/tests export-ignore
+29
View File
@@ -0,0 +1,29 @@
---
name: Bug report
about: Something doesn't work as expected
labels: bug
---
**ADconsole version / commit:**
**PHP version:**
**Deployment:** Docker / bare metal (Apache | nginx)
**Database:** MariaDB | MySQL | PostgreSQL | SQLite
## What happens
<!-- Exact error text. With APP_DEBUG=true and the relevant storage/logs/laravel.log
lines — REDACT DNs, hostnames and account names. -->
## Expected
## Steps to reproduce
1.
2.
3.
## Checked
- [ ] `docs/troubleshooting.md`
- [ ] `LDAP_BASE_DN` covers users, computers and groups
- [ ] `php artisan optimize:clear` after the last `.env` change
+9
View File
@@ -0,0 +1,9 @@
# Contact links shown above the "New issue" templates.
blank_issues_enabled: false
contact_links:
- name: Security vulnerability
url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/src/branch/main/SECURITY.md
about: Do not file security issues publicly — email the maintainer (see SECURITY.md).
- name: Question / help
url: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/issues
about: Deployment and configuration questions — search existing issues first.
+28
View File
@@ -0,0 +1,28 @@
---
name: Feature request
about: Suggest an improvement
labels: enhancement
---
## Problem
<!-- What are you trying to do that ADconsole makes hard or impossible today? -->
## Proposed solution
<!-- What should it do instead? Which module / screen? -->
## Directory impact
- [ ] Needs a new AD read/write the service account isn't normally delegated
(which right? see docs/active-directory.md)
- [ ] Read-only
## Alternatives considered
<!-- Workarounds you've tried, or other approaches. -->
## Scope check
- [ ] This is *not* GPO content editing (SYSVOL) or a general LDAP browser —
those are explicit non-goals.
+19
View File
@@ -0,0 +1,19 @@
<!-- Thanks for contributing! Keep PRs focused on one change. -->
## What & why
<!-- What does this change, and what problem does it solve? Link the issue. -->
## Impact
- [ ] New or changed **AD operation** — documented the required right in `docs/active-directory.md`
- [ ] **Database migration** included
- [ ] New **`.env` key** — added to `.env.example` and `docs/configuration.md`
- [ ] New user-facing text — added to **both** `lang/pl` and `lang/en`
- [ ] `CHANGELOG.md` updated (Unreleased)
## Checks
- [ ] `php artisan test` green (`DEPR` in the summary is fine)
- [ ] `vendor/bin/pint --test` green
- [ ] Tested manually against a domain controller / with the fake guard
+50
View File
@@ -0,0 +1,50 @@
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
tests:
name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ['8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: ldap, pdo_sqlite, mbstring, curl, zip, openssl
coverage: none
tools: composer:v2
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: ~/.composer/cache
key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }}
restore-keys: composer-${{ matrix.php }}-
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- name: Prepare environment
run: |
cp .env.example .env
php artisan key:generate
- name: Code style (Pint)
run: vendor/bin/pint --test
- name: Tests
run: php artisan test
+43
View File
@@ -0,0 +1,43 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.env.*.local
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
_ide_helper_models.php
.phpstorm.meta.php
Homestead.json
Homestead.yaml
Thumbs.db
# Local Docker overrides (ports, extra services) - not for distribution.
/docker/compose.override.yaml
/docker/compose.override.yml
# AI assistant / editor local state.
/.claude
/.aider*
# Legacy panel config file, replaced by the `panel_settings` table. A leftover
# copy (or its *.migrated rename) must never be committed.
/storage/app/panel_settings.json
/storage/app/panel_settings.json.migrated
+2
View File
@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true
+110
View File
@@ -0,0 +1,110 @@
# Changelog
All notable changes to ADconsole. Format follows
[Keep a Changelog](https://keepachangelog.com/).
Versioning (from `v1.0.0` on) is [semantic](https://semver.org/): **MAJOR** for
incompatible changes to configuration, the database schema or AD-permission
requirements; **MINOR** for backwards-compatible features; **PATCH** for
backwards-compatible fixes. Each release is a git tag `vX.Y.Z`; the latest tag on
`main` is the supported version. Upgrade notes live in
[`docs/upgrading.md`](docs/upgrading.md).
## [Unreleased]
_Nothing yet._
## [1.0.0] - 2026-09-08
First public release. Consolidates the prior internal builds (`0.1.0`) into a
tagged, documented release published at
<https://gitea.kzbikowski.pl/kzbkowski/ADconsle>.
### Added
- **Audit log** (`/audit`, `AuditController`): read-only, filterable record of
every change the panel makes to AD — actor, action slug, target, optional
reason, a `changes` diff and a `context` blob. New `panel_audit` table, written
only through `App\Services\AuditLogger` (best-effort; a log failure never
aborts the AD operation). Viewing a LAPS / BitLocker secret is audited too.
- **User offboarding** (`/users/{sam}/offboard` + `/users/bulk/offboard`):
one action that disables the account and randomises its password, strips group
memberships (optionally keeping the primary group), moves the object to a
chosen OU and sets an expiry date + description note — run in a safe order
(attribute changes → group removal → OU move last, since the move changes the
DN).
- **Settings → Audit log**: a *"require a reason for destructive actions"*
switch (`panel_settings` → `audit.require_reason`) that makes the reason field
mandatory for disable / delete / move / offboard.
- **Users**: CSV export of the current (filtered) list — `GET /users/export`,
streamed, UTF-8 with a BOM for Excel, one column per configured attribute.
- **Dashboard**: tiles are now links to the matching filtered user list;
problem tiles (locked / expired password / expired account) are highlighted
when non-zero; relative "recalculated N ago" indicator that turns amber when
the cache is stale; global empty state when AD returns nothing; password-age
histogram (`<30 / 30–60 / 60–90 / >90 days`).
- **Users**: `filter=` query parameter for the dashboard drill-downs
(`locked`, `pwd_never`, `pwd_expiring`, `pwd_expired`, `inactive`,
`account_expired`) with a clearable filter chip.
- Collapsible OU-tree sidebar (narrow rail with hover-to-expand, or pinned),
remembered per browser.
- `LDAP_SSL`, `LDAP_TLS_VERIFY`, `APP_DOMAIN` environment keys.
- Docker: self-bootstrapping entrypoint (deps, key, migrate, cache),
`docker/compose.override.yaml.template` for running without Traefik, opcache
in the image, `.dockerignore`.
- Full `docs/` set (installation, configuration, Active Directory, architecture,
deployment, development, troubleshooting, upgrading, publishing), plus
`LICENSE`, `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md`, GitHub
issue/PR templates, a CI workflow (Pint + PHPUnit on PHP 8.2–8.4), and this
changelog.
### Changed
- **URL segments are now English** (route *names* were already): `/dashboard`,
`/users`, `/computers`, `/groups`, `/organisation`, `/gpo`, `/settings`,
`/login`, `/logout`, `/language/{locale}`, `/about`; sub-paths too
(`/users/search`, `/users/bulk/…`, `/users/{sam}/reset-password`,
`/computers/{cn}/reset-trust`, `/groups/{cn}/members`, `/settings/attributes`,
…). Bookmarks to the old Polish paths (`/pulpit`, `/uzytkownicy`, …) now 404.
Blade `route()` calls were unaffected; hardcoded fetch/`url()` paths in the
module JS (`/users/{sam}/template`, `.../photo`, `.../groups`,
`/groups/{cn}/members`, single-action templates in the bulk modals) and the
settings-page tab anchors (`#attributes`) were updated to match.
- **`manager` attribute** relabelled to *Kierownik*; in the user edit dialog it
is now a name search only — the raw DN field is hidden (still submitted), with
a clear button to unset the manager.
- **Panel configuration moved out of `storage/app/panel_settings.json` into the
database:**
- general settings (password policy & generator, reset-dialog defaults,
encryption key) → `panel_settings` table (`App\Models\PanelSetting`);
- AD attribute / column definitions → dedicated `panel_attributes` table with
real columns (`key`, `label`, `ldap`, `category`, `type`, `is_column`,
`position`) and one row per attribute (`App\Models\PanelAttribute`).
Two automatic migrations: the first imports the legacy JSON file (renaming it
`*.migrated`), the second moves the attribute list into its own table and
drops the now-unused `attributes` / `domains` / `users_table` keys from the
`panel_settings` row. `PanelSettingsService` is still the only entry point.
- Dashboard aggregation streams every account with `chunk()` instead of one
`paginate(500)` page — lower memory, and unambiguously counts the whole
domain.
- Search inputs, the "search" buttons and the account-status filter restyled as
borderless / outline controls; the navbar search matches. Removed the static
"Domain: …" label from the navbar.
- `config/ldap.php`: `LDAP_SSL` (LDAPS) / `LDAP_TLS` (STARTTLS) now map to the
correct LdapRecord v4 options, plus a `LDAP_TLS_VERIFY` cert-check switch and
tidied comments.
- README rewritten; `.env.example` fully documented with production-safe
defaults (`APP_ENV=production`, `APP_DEBUG=false`).
- `composer.json` package renamed to `adconsole/adconsole`; added `test` /
`lint` / `format` scripts.
### Fixed
- Dashboard report links used a dead `scope=all` parameter (the list reads
`all_ou`), so drilling into a user from another OU found nothing.
## [0.1.0] - internal
Initial internal release: users, computers, groups, read-only GPO, org chart,
dashboard, panel configuration, PL/EN UI, LDAP-group login. Never tagged or
published; superseded by `1.0.0`.
[Unreleased]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/compare/v1.0.0...HEAD
[1.0.0]: https://gitea.kzbikowski.pl/kzbkowski/ADconsle/releases/tag/v1.0.0
+8
View File
@@ -0,0 +1,8 @@
# CLAUDE.md
Project context for AI coding agents and new contributors lives in
**[`CLAUDE/AGENTS.md`](CLAUDE/AGENTS.md)** — read it first. See
[`CLAUDE/README.md`](CLAUDE/README.md) for what else is in that folder. Deeper
human-facing docs are under [`docs/`](docs/).
@CLAUDE/AGENTS.md
+79
View File
@@ -0,0 +1,79 @@
# AGENTS.md — working on ADconsole
Context for AI coding agents and new contributors. Keep it accurate when you
change the architecture.
## What this is
A Laravel 11 + [LdapRecord](https://ldaprecord.com/) web panel that manages an
Active Directory domain: users, computers, groups and **read-only** GPO, plus a
dashboard and an org chart. PHP 8.2+. Interface is PL/EN.
## Golden rules
1. **No SQL storage for AD objects.** Every read/write of a user, computer or
group goes through LdapRecord straight to a domain controller. The database
(`panel_settings` + `panel_attributes` + `panel_audit`, plus Laravel's
`sessions` / `cache` / `jobs`) backs the application layer only. `panel_audit`
records *what the panel did* to AD — it is not a mirror of AD state.
2. **Auth is the `ldap` guard** (LdapRecord plain-bind, no local accounts).
Only members of `AD_LOGIN_GROUP` may sign in
(`App\Ldap\Rules\MemberOfLoginGroup`, honours nested + primary group). Every
panel route is behind `auth:ldap`; the admin profile lives in the session
key `ad_admin`.
3. **i18n everywhere.** User-facing strings come from `lang/{pl,en}/*.php` via
`__('namespace.key')`. Never hard-code copy in Blade/PHP. Add a key to both
locales. Default locale is `pl`; `config/locales.php` is the single source of
the available-language list.
4. **Some `lang/pl/*` values are asserted verbatim by tests** (e.g.
`tests/Feature/AttributeConfigTest`). Changing an existing PL string can
break a test — check before editing.
5. **Docroot is `public/`.** Do not add `.htaccess` deny rules elsewhere as a
substitute.
## Layout
| Path | Role |
|---|---|
| `app/Http/Controllers/Admin/*` | one controller per module (Users, Computers, Groups, Gpo, OrgChart, Dashboard, Audit, Config) |
| `app/Ldap/User.php` | AD user model + attribute map + `toPanelProfile()` |
| `app/Ldap/Rules/MemberOfLoginGroup.php` | login authorisation rule |
| `app/Services/` | `PanelSettingsService` (config, DB-backed), `AttributeCipher`, `LdapAttributeHelper` (UAC bits, FileTime), `OuTreeService` (cached OU tree), `AuditLogger` (writes `panel_audit`; never throws — a log failure is only `report()`ed) |
| `app/Models/PanelSetting.php` | key→JSON row: general panel config (`panel_settings`) |
| `app/Models/PanelAttribute.php` | one row per configured AD attribute (`panel_attributes`, ordered by `position`) |
| `app/Models/AuditLog.php` | one row per audited panel action (`panel_audit`); written only via `AuditLogger`, read only by `AuditController` |
| `config/ldap.php` | connection, `auth.login_group`, default OUs, attribute map |
| `resources/views/admin/*` | Blade views; shared bits in `components/` + `partials/` |
| `public/css/style.css`, `public/js/main.js` | hand-written assets loaded directly (no Vite build needed) |
| `routes/web.php` | all routes; English URL segments and route names |
## Configuration
- **Deployment config** → `.env` (see `.env.example` for every key). Read-only
at runtime; nothing writes back to it.
- **In-app config** (`/settings`) → via `PanelSettingsService`:
- general settings (password policy / generator, reset-dialog defaults,
attribute-encryption key, `audit.require_reason`) → one JSON row in
`panel_settings`;
- AD attribute / column definitions → one row each in `panel_attributes`
(columns: `key`, `label`, `ldap`, `category`, `type`, `is_column`,
`position`).
Both fall back to `PanelSettingsService::defaults()` /
`defaultAttributes()` when empty.
## Running things
```sh
composer install
cp .env.example .env && php artisan key:generate
php artisan migrate
php artisan test # SQLite :memory:, ~40 tests
vendor/bin/pint # code style
```
`php artisan test` prints a harmless PHP 8.5 `PDO::MYSQL_ATTR_SSL_CA`
deprecation that PHPUnit flags on every test as `DEPR` — that is **not** a
failure.
See `docs/` for installation, AD setup, deployment, architecture and
troubleshooting.
+22
View File
@@ -0,0 +1,22 @@
# `CLAUDE/` — AI agent & contributor context
This folder holds the machine- and newcomer-oriented context for the project,
kept out of the repo root to keep it tidy.
| File | What it is |
|---|---|
| [`AGENTS.md`](AGENTS.md) | The architecture, golden rules, layout and dev loop in one page. The single source of truth for "how this project is built and why". |
The root [`../CLAUDE.md`](../CLAUDE.md) is a thin pointer that `@`-imports
`AGENTS.md` so Claude Code / Cursor / other agents still auto-load the context
from the repository root.
Human-facing documentation (installation, configuration, deployment,
troubleshooting, …) lives in [`../docs/`](../docs/). Contribution rules are in
[`../CONTRIBUTING.md`](../CONTRIBUTING.md).
## Keeping it current
Update `AGENTS.md` whenever you change the architecture, the golden rules, the
top-level layout, or the commands in the dev loop. If you rename or move it,
update the `@`-import in `../CLAUDE.md` and the links in `../CONTRIBUTING.md`.
+46
View File
@@ -0,0 +1,46 @@
# Code of Conduct
## Our pledge
We as members, contributors and maintainers pledge to make participation in the
ADconsole project a harassment-free experience for everyone, regardless of age,
body size, visible or invisible disability, ethnicity, sex characteristics,
gender identity and expression, level of experience, education, socio-economic
status, nationality, personal appearance, race, religion, or sexual identity
and orientation.
## Our standards
Examples of behaviour that contributes to a positive environment:
- Being respectful of differing opinions, viewpoints and experiences
- Giving and gracefully accepting constructive feedback
- Focusing on what is best for the project and its users
- Showing empathy towards other community members
Examples of unacceptable behaviour:
- Sexualised language or imagery, and unwelcome sexual attention
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information without explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement
Instances of abusive, harassing or otherwise unacceptable behaviour may be
reported to the project maintainer through the channel listed in
[`SECURITY.md`](SECURITY.md) (mark it "conduct"). All complaints will be
reviewed and investigated promptly and fairly. Maintainers are obligated to
respect the privacy and security of the reporter.
Maintainers may remove, edit or reject contributions that violate this Code of
Conduct, and may temporarily or permanently ban any contributor for behaviours
they deem inappropriate, threatening, offensive or harmful.
## Attribution
Adapted from the [Contributor Covenant][cc], version 2.1.
[cc]: https://www.contributor-covenant.org/version/2/1/code_of_conduct/
+67
View File
@@ -0,0 +1,67 @@
# Contributing to ADconsole
Thanks for helping out. This is a small, focused project — a web UI for everyday
Active Directory administration. Contributions that keep it small and sharp are
very welcome.
## Before you start
- Read [`CLAUDE/AGENTS.md`](CLAUDE/AGENTS.md) (the architecture in one page) and
[`docs/architecture.md`](docs/architecture.md).
- By participating you agree to the [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md).
- For anything non-trivial, open an issue first so we can agree on the approach.
## Ground rules
1. **No SQL storage for AD objects.** All directory reads/writes go through
LdapRecord. The database is for app infrastructure and `panel_settings` only.
2. **Every user-facing string is a translation key** in *both*
`lang/pl/*` and `lang/en/*`. No inline copy.
3. **Keep it dependency-light.** No new runtime Composer/npm package without a
clear reason in the issue.
4. **Least privilege.** New AD operations must be documented in
[`docs/active-directory.md`](docs/active-directory.md) with the exact right
they require.
5. **Security-sensitive by nature** — think about what a hostile panel user or a
compromised service account could do with your change.
## Dev loop
```sh
composer install
cp .env.example .env && php artisan key:generate
php artisan migrate
php artisan test
vendor/bin/pint
```
See [`docs/development.md`](docs/development.md) for the details (SQLite for dev,
the fake `ldap` guard in tests, the harmless `DEPR` tag, etc.).
## Pull requests
- Branch from `main`; keep the PR focused on one thing.
- Include tests for logic changes. Feature tests may seed caches / fake the
guard rather than hitting a real DC (see `tests/Feature/DashboardTest.php`).
- Run `php artisan test` and `vendor/bin/pint --test` — both must be green
(`DEPR` in the test summary is fine; `failed` is not).
- Update the relevant `docs/` page and add a `CHANGELOG.md` entry under
*Unreleased*.
- Describe the change, the motivation, and any AD permission / migration impact
in the PR body.
## Reporting bugs
Open an issue with: ADconsole version / commit, PHP version, how it's deployed
(Docker / bare metal), the exact error (with `APP_DEBUG=true` and the relevant
`storage/logs/laravel.log` lines — **redact DNs, hostnames and account names**),
and steps to reproduce.
## Security issues
**Do not** open a public issue — see [`SECURITY.md`](SECURITY.md).
## Licence
By contributing you agree your work is released under the project's
[MIT Licence](LICENSE).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Kacper Żbikowski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+202
View File
@@ -0,0 +1,202 @@
# ADconsole
**A self-hosted web panel for day-to-day Active Directory administration —
users, computers, groups and read-only Group Policy — built on Laravel 11 and
[LdapRecord](https://ldaprecord.com/).**
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
![PHP 8.2+](https://img.shields.io/badge/php-8.2%2B-777bb4.svg)
![Laravel 11](https://img.shields.io/badge/laravel-11-ff2d20.svg)
ADconsole gives helpdesk and sysadmin staff a clean UI for the tasks that
otherwise mean RSAT, `dsa.msc` or PowerShell: reset a password, unlock an
account, move an object between OUs, add someone to a group, check who has a
stale password. It talks **directly to a domain controller** over LDAP — there
is no database copy of your directory to drift out of sync.
- **No AD data at rest.** Every read and write goes straight to a DC through
LdapRecord. The SQL database only holds the app's own sessions, cache and
panel settings.
- **Domain login, group-gated.** Admins sign in with their own AD account;
access is limited to members of one AD group you nominate.
- **Bilingual UI** — Polish and English, switchable per session.
- **Deploys in minutes** — a `docker compose up` stack, or drop it on any
PHP 8.2+ host.
> ⚠️ ADconsole performs privileged directory operations (password resets,
> account enable/disable, deletions). Read [`SECURITY.md`](SECURITY.md) and the
> [hardening checklist](docs/deployment.md#hardening-checklist) before exposing it.
---
## Features
| Module | What it does |
|---|---|
| **Dashboard** (`/dashboard`) | Domain health tiles (disabled / locked / stale-password / inactive / expired accounts, object counts), password-age histogram, "expiring soon / inactive / newly created" mini-reports. Tiles link straight to the matching filtered list. Cached, recalculated on demand. |
| **Users** (`/users`) | OU tree + full-text search, create, view/edit (configurable attribute set), password reset (single **and** bulk), move OU (single and bulk), password-expiry policy, enable/disable, group membership, photo, CSV export of the current (filtered) list, and a one-click **offboarding** flow (disable + randomise password + strip group memberships + move OU + set expiry, in a safe order). |
| **Computers** (`/computers`) | OU tree, OS filter, LAPS (legacy `ms-Mcs-AdmPwd` + Windows LAPS) and BitLocker recovery keys, reset trust account, enable/disable the object, delete. |
| **Groups** (`/groups`) | Type/scope filter, create, edit description & mail, manage members, delete. |
| **Org chart** (`/organisation`) | `manager` → `directReports` hierarchy rendered with d3-org-chart. |
| **GPO** (`/gpo`) | **Read-only** list of Group Policy objects and their OU links, plus a simplified RSoP lookup. Editing policy content (SYSVOL) is out of scope. |
| **Audit log** (`/audit`) | **Read-only** record of every change the panel makes to AD — actor, action, target, optional reason and a before/after diff — filterable by admin, action, object and date. Stored in the `panel_audit` table (application data, never AD). |
| **Configuration** (`/settings`) | Password policy & generator, attribute-encryption key, an optional "require a reason for destructive actions" switch, and the definition of which AD attributes show up as list columns / profile fields. Stored in the `panel_settings` / `panel_attributes` tables. |
Full walkthrough: [`docs/architecture.md`](docs/architecture.md).
## Requirements
- **PHP 8.2+** with `ldap`, `pdo_mysql` (or your DB driver), `mbstring`, `curl`,
`zip`, `openssl`
- **Composer 2**
- A SQL database (MariaDB/MySQL recommended; SQLite/PostgreSQL work too) — for
sessions, cache and panel settings only
- Network reachability to a **domain controller**, ideally over **LDAPS (636)**
or STARTTLS — password writes fail on an unencrypted connection
- An **AD service account** that can read your directory and modify the objects
you want to manage (see [`docs/active-directory.md`](docs/active-directory.md))
- For the container image: Docker + Docker Compose v2
## Quick start — Docker
```sh
git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git adconsole && cd adconsole
cp .env.example .env
# edit .env: APP_URL/APP_DOMAIN, DB_* passwords, and the whole LDAP_* / AD_* block
# With an external Traefik proxy (see docker/compose.yaml):
docker compose -f docker/compose.yaml up -d --build
# Or without a proxy — publish a local port instead:
cp docker/compose.override.yaml.template docker/compose.override.yaml
docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
# -> http://localhost:8080 (set APP_URL to match)
```
The container entrypoint installs dependencies, generates `APP_KEY`, runs
migrations and warms the caches on first boot. Details and production notes:
[`docs/deployment.md`](docs/deployment.md).
## Quick start — bare metal
```sh
git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git adconsole && cd adconsole
composer install --no-dev --optimize-autoloader
cp .env.example .env
php artisan key:generate
# edit .env (LDAP_* / AD_* / DB_*)
php artisan migrate
php artisan config:cache && php artisan route:cache && php artisan view:cache
# point your web server's document root at ./public
# (dev only:) php artisan serve
```
Step-by-step, including Apache/nginx vhosts: [`docs/installation.md`](docs/installation.md).
## Configuration at a glance
| Where | What | How |
|---|---|---|
| `.env` | Connection & deployment settings: DC hosts, service account, base DN, default OUs, login group, DB, app URL. | Edit the file, then `php artisan config:cache`. Every key is documented in [`.env.example`](.env.example) and [`docs/configuration.md`](docs/configuration.md). |
| Database (`/settings`) | In-app settings: password policy/generator + encryption key (`panel_settings`), and the AD attribute / column definitions (`panel_attributes`). | Edited in the UI; persisted via `App\Services\PanelSettingsService`. |
Who can log in is `AD_LOGIN_GROUP`. What the service account may change is set by
**AD delegation**, not by ADconsole — see [`docs/active-directory.md`](docs/active-directory.md).
## How it works (short version)
- `config/ldap.php` defines one connection from `LDAP_*`. `App\Ldap\User`
extends the LdapRecord AD user model.
- Login = LdapRecord plain-bind with the typed credentials; then
`App\Ldap\Rules\MemberOfLoginGroup` checks membership of `AD_LOGIN_GROUP`
(nested + primary group aware). Success stores `ad_admin` in the session; all
`/…` panel routes sit behind `auth:ldap`.
- Controllers query/modify AD live. Heavy reads (OU trees, dashboard, org chart)
are cached on the `file` store for a few minutes with a manual "recalculate"
button.
- `LdapAttributeHelper` decodes the awkward bits (userAccountControl bit flags,
Windows FileTime timestamps, `accountExpires` "never" sentinels).
- Sensitive attributes (e.g. cloud extension attributes) can be stored encrypted
with a key set in the panel — `App\Services\AttributeCipher`.
More: [`docs/architecture.md`](docs/architecture.md).
## Tests
```sh
php artisan test # ~40 tests, SQLite :memory:
vendor/bin/pint --test # code style
```
A PHP 8.5 `PDO::MYSQL_ATTR_SSL_CA` deprecation makes PHPUnit tag every test
`DEPR` — that is noise, not a failure.
## Project layout
```
app/
Http/Controllers/Admin/ one controller per module
Ldap/ AD user model + login rule
Services/ PanelSettings, AttributeCipher, LdapAttributeHelper, OuTree
Models/ PanelSetting (JSON config row) + PanelAttribute (one row per attribute)
config/ldap.php connection, login group, default OUs, attribute map
resources/views/admin/ Blade UI (+ components/ + partials/)
public/css/style.css hand-written styles (no build step)
public/js/main.js hand-written JS
routes/web.php English URL segments and route names
lang/{pl,en}/ structured translation keys
docs/ installation / AD / deployment / architecture / …
docker/ compose stack + PHP image
```
## Documentation
Everything lives in [`docs/`](docs/):
[installation](docs/installation.md) ·
[configuration](docs/configuration.md) ·
[Active Directory setup](docs/active-directory.md) ·
[architecture](docs/architecture.md) ·
[deployment](docs/deployment.md) ·
[development](docs/development.md) ·
[troubleshooting](docs/troubleshooting.md) ·
[upgrading](docs/upgrading.md) ·
[publishing](docs/publishing.md).
## Roadmap / non-goals
Planned: server-side password-policy validation before write; optional TOTP for
off-LAN login; export of the audit log.
Out of scope: editing GPO **content** (SYSVOL/SMB); being a general LDAP browser;
managing non-AD directories.
## License & legal
Released under the [MIT License](LICENSE) — provided "as is", without warranty.
`Copyright (c) 2026 Kacper Żbikowski`.
ADconsole is built on the [Laravel framework](https://laravel.com) (MIT) and the
[laravel/laravel](https://github.com/laravel/laravel) application skeleton (MIT);
the skeleton's MIT terms are satisfied by this repository's own
[`LICENSE`](LICENSE). Third-party PHP and JavaScript dependencies are pulled in
at build time by Composer / a CDN and are **not** vendored in this repository;
each keeps its own licence (all permissive — MIT / BSD / Apache-2.0). Run
`composer licenses` after `composer install` for the authoritative list.
*Active Directory, Windows, Windows Server and Group Policy are trademarks of
Microsoft Corporation. ADconsole is an independent project and is not affiliated
with, endorsed or sponsored by Microsoft.*
When you deploy ADconsole your organisation is the **data controller** for the
personal data it reads from AD (names, logins, e-mail, phone, photo, …). You are
responsible for the lawful basis, retention and data-subject rights; restrict
panel access to authorised admins and always use LDAPS/STARTTLS. See the
in-app **About → Legal** page and [`SECURITY.md`](SECURITY.md).
## Contributing
Issues and PRs welcome — see [`CONTRIBUTING.md`](CONTRIBUTING.md) and the
[`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md). Security reports:
[`SECURITY.md`](SECURITY.md).
+53
View File
@@ -0,0 +1,53 @@
# Security policy
## Reporting a vulnerability
**Please do not open a public issue for security problems.**
Email the maintainer at **`mail@kzbikowski.pl`** with the subject prefixed
`[ADconsole security]`. If the host offers private vulnerability reporting, that
channel is fine too.
Please include:
- affected version / commit
- a description and impact assessment
- reproduction steps or a proof of concept
- any suggested fix
You'll get an acknowledgement within a few working days. Please give a
reasonable window for a fix before public disclosure; we'll credit you in the
release notes unless you'd rather stay anonymous.
## Scope
In scope: authentication and the `AD_LOGIN_GROUP` gate, authorisation between
modules, LDAP query/filter construction (injection), handling of the service
account credentials, the attribute-encryption feature, session handling, CSRF,
XSS in the Blade views, privilege escalation via the bulk actions.
Out of scope: issues that require a already-privileged panel account performing
actions it is legitimately delegated to do in AD; missing hardening that is the
operator's responsibility (see below); vulnerabilities in Laravel, LdapRecord or
other dependencies (report those upstream — do tell us so we can pin/patch).
## Operator responsibilities
ADconsole is a privileged tool. When you deploy it, **you** are responsible for:
- Serving it over HTTPS only, reachable from admin networks / VPN only.
- `APP_DEBUG=false`, `APP_ENV=production`.
- An encrypted LDAP channel (`LDAP_SSL` / `LDAP_TLS`) and, ideally,
`LDAP_TLS_VERIFY=true`.
- Delegating the service account **only** the AD rights you actually use
([docs/active-directory.md](docs/active-directory.md)).
- Keeping `AD_LOGIN_GROUP` small and audited.
- Protecting `.env` (file mode `600`) — it contains the service-account password.
- Applying updates promptly.
See the [hardening checklist](docs/deployment.md#hardening-checklist).
## Supported versions
The latest release on `main` receives security fixes. This is a
community/self-hosted project with no long-term-support branches.
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\AuditLog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AuditController extends Controller
{
/**
* Dziennik zmian panelu z filtrami (administrator / akcja / obiekt / okres).
*/
public function index(Request $request): View
{
$actor = trim((string) $request->query('actor', ''));
$action = trim((string) $request->query('action', ''));
$q = trim((string) $request->query('q', ''));
$from = $request->date('from');
$to = $request->date('to');
$perPage = max(10, min(200, (int) $request->query('per_page', 25)));
$entries = AuditLog::query()
->when($actor !== '', fn ($b) => $b->where('actor', 'like', "%{$actor}%"))
->when($action !== '', fn ($b) => $b->where('action', $action))
->when($q !== '', fn ($b) => $b->where(function ($b) use ($q) {
$b->where('target', 'like', "%{$q}%")->orWhere('target_dn', 'like', "%{$q}%");
}))
->when($from, fn ($b) => $b->where('created_at', '>=', $from->startOfDay()))
->when($to, fn ($b) => $b->where('created_at', '<=', $to->endOfDay()))
->orderByDesc('created_at')
->orderByDesc('id')
->paginate($perPage)
->withQueryString();
return view('admin.audit.index', [
'entries' => $entries,
'actions' => AuditLog::query()->select('action')->distinct()->orderBy('action')->pluck('action'),
'filters' => compact('actor', 'action', 'q') + [
'from' => $request->query('from'),
'to' => $request->query('to'),
],
]);
}
}
@@ -0,0 +1,366 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
use App\Services\LdapAttributeHelper;
use App\Services\OuTreeService;
use App\Services\PanelSettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use LdapRecord\Models\ActiveDirectory\Computer;
use LdapRecord\Models\ActiveDirectory\Entry;
use LdapRecord\Models\ModelNotFoundException;
class ComputerController extends Controller
{
public function __construct(protected AuditLogger $audit) {}
public function index(Request $request, OuTreeService $ouTree)
{
if ($request->boolean('refresh_ou')) {
OuTreeService::flush();
}
$allOu = $request->boolean('all_ou');
$ouDn = $request->query('ou') ?: ($allOu ? null : $ouTree->rootDn('computers'));
$search = trim((string) $request->query('q', ''));
$os = $request->query('os');
$inactiveOnly = $request->boolean('inactive');
$perPage = max(10, min(200, (int) $request->query('per_page', 25)));
$query = Computer::query();
if ($ouDn) {
$query->in($ouDn)->recursive();
}
if ($search !== '') {
$searchAttrs = ['cn', 'dnshostname', 'operatingsystem', 'operatingsystemversion', 'description', 'samaccountname'];
$query->orFilter(function ($q) use ($searchAttrs, $search) {
foreach ($searchAttrs as $attr) {
$q->whereContains($attr, $search);
}
});
}
if ($os) {
$query->whereContains('operatingsystem', $os);
}
// 1. Pobieramy dopasowane komputery z LDAP (paged results control).
$allComputers = $query
->select(['cn', 'dnshostname', 'operatingsystem', 'operatingsystemversion', 'description', 'distinguishedname', 'useraccountcontrol', 'lastlogontimestamp'])
->orderBy('cn')
->paginate(500);
// 2. Mapujemy obiekty LdapRecord do formatu tabeli
$allRows = collect($allComputers)->map(fn (Computer $c) => $this->presentComputer($c));
// 2a. Filtr "nieaktywne >= 90 dni" (z kafelka pulpitu) - liczony w PHP.
if ($inactiveOnly) {
$cut = now()->subDays(90);
$allRows = $allRows->filter(
fn (array $r) => ! $r['disabled'] && (! $r['last_logon'] || $r['last_logon']->lt($cut))
)->values();
}
// 3. Budujemy natywny LengthAwarePaginator dla Laravela
$page = LengthAwarePaginator::resolveCurrentPage();
$total = $allRows->count();
$computers = new LengthAwarePaginator(
$allRows->forPage($page, $perPage)->values(),
$total,
$perPage,
$page,
['path' => LengthAwarePaginator::resolveCurrentPath()]
);
$computers->appends(collect($request->query())->except(['partial', 'page'])->all());
$viewData = [
'currentOu' => $ouDn,
'allOu' => $allOu,
'search' => $search,
'osFilter' => $os,
'inactiveOnly' => $inactiveOnly,
'computers' => $computers,
'rows' => collect($computers->items()),
];
if ($request->boolean('partial')) {
return view('admin.computers.partials.results', $viewData);
}
return view('admin.computers.index', $viewData + [
'ouTree' => $ouTree->tree('computers'),
'requireReason' => app(PanelSettingsService::class)->requireReason(),
]);
}
/**
* Włącza / wyłącza obiekt komputera w AD.
*/
/**
* Szczegóły komputera: LAPS (hasło lokalnego administratora, oba warianty)
* oraz klucze odzyskiwania BitLocker (obiekty msFVE-RecoveryInformation).
*/
public function show(string $cn): JsonResponse
{
$computer = Computer::query()
->select([
'cn', 'dnshostname', 'operatingsystem', 'operatingsystemversion',
'distinguishedname', 'useraccountcontrol', 'lastlogontimestamp', 'whencreated', 'description',
'ms-Mcs-AdmPwd', 'ms-Mcs-AdmPwdExpirationTime',
'msLAPS-Password', 'msLAPS-EncryptedPassword', 'msLAPS-PasswordExpirationTime',
])
->where('cn', '=', $cn)
->first();
if (! $computer) {
throw new ModelNotFoundException("Nie znaleziono komputera: {$cn}");
}
// LAPS - wariant klasyczny (ms-Mcs-AdmPwd) i Windows LAPS (msLAPS-Password: JSON).
$lapsLegacy = $computer->getFirstAttribute('ms-mcs-admpwd');
$lapsLegacyExp = LdapAttributeHelper::windowsTimestampToCarbon($computer->getFirstAttribute('ms-mcs-admpwdexpirationtime'));
$winLapsRaw = $computer->getFirstAttribute('mslaps-password');
$winLaps = null;
if ($winLapsRaw) {
$decoded = json_decode($winLapsRaw, true);
$winLaps = is_array($decoded)
? ['account' => $decoded['n'] ?? null, 'password' => $decoded['p'] ?? null, 'updated' => $decoded['t'] ?? null]
: ['account' => null, 'password' => $winLapsRaw, 'updated' => null];
}
$winLapsEncrypted = (bool) $computer->getFirstAttribute('mslaps-encryptedpassword');
$winLapsExp = LdapAttributeHelper::windowsTimestampToCarbon($computer->getFirstAttribute('mslaps-passwordexpirationtime'));
// BitLocker - obiekty potomne msFVE-RecoveryInformation pod obiektem komputera.
$bitlocker = [];
try {
$recovery = Entry::query()
->in($computer->getDn())
->whereEquals('objectclass', 'msFVE-RecoveryInformation')
->select(['cn', 'msfve-recoverypassword', 'msfve-recoveryguid', 'whencreated'])
->paginate(200);
foreach ($recovery as $rec) {
$guid = $rec->getFirstAttribute('msfve-recoveryguid');
$bitlocker[] = [
'password' => $rec->getFirstAttribute('msfve-recoverypassword'),
'guid' => $guid ? bin2hex($guid) : ($rec->getFirstAttribute('cn')),
'created' => $this->parseLdapDate($rec->getFirstAttribute('whencreated')),
];
}
} catch (\Throwable) {
// brak dostępu / brak obiektów
}
$lapsAvailable = (bool) ($lapsLegacy || $winLaps || $winLapsEncrypted);
// Odczyt wrażliwych danych też trafia do dziennika (kto zobaczył hasło
// lokalnego admina / klucze BitLocker komputera).
if ($lapsAvailable) {
$this->audit->log('computer.laps_view', [
'target_type' => 'computer', 'target' => $computer->getFirstAttribute('cn'), 'target_dn' => $computer->getDn(),
]);
}
if ($bitlocker !== []) {
$this->audit->log('computer.bitlocker_view', [
'target_type' => 'computer', 'target' => $computer->getFirstAttribute('cn'), 'target_dn' => $computer->getDn(),
'context' => ['keys' => count($bitlocker)],
]);
}
return response()->json([
'cn' => $computer->getFirstAttribute('cn'),
'dns_hostname' => $computer->getFirstAttribute('dnshostname'),
'os' => trim(($computer->getFirstAttribute('operatingsystem') ?? '').' '.($computer->getFirstAttribute('operatingsystemversion') ?? '')),
'dn' => $computer->getDn(),
'description' => $computer->getFirstAttribute('description'),
'last_logon' => optional(LdapAttributeHelper::windowsTimestampToCarbon($computer->getFirstAttribute('lastlogontimestamp')))->format('Y-m-d H:i'),
'created' => $this->parseLdapDate($computer->getFirstAttribute('whencreated')),
'laps' => [
'legacy_password' => $lapsLegacy,
'legacy_expires' => optional($lapsLegacyExp)->format('Y-m-d H:i'),
'windows' => $winLaps,
'windows_encrypted' => $winLapsEncrypted,
'windows_expires' => optional($winLapsExp)->format('Y-m-d H:i'),
'available' => $lapsAvailable,
],
'bitlocker' => $bitlocker,
]);
}
protected function parseLdapDate(?string $ldapDate): ?string
{
if (! $ldapDate) {
return null;
}
try {
return Carbon::createFromFormat('YmdHis.0Z', $ldapDate)->format('Y-m-d H:i');
} catch (\Throwable) {
return $ldapDate;
}
}
public function toggleEnabled(Request $request, string $cn): RedirectResponse
{
$computer = $this->findByCn($cn);
$uac = (int) ($computer->getFirstAttribute('userAccountControl') ?? 0);
if ($uac & LdapAttributeHelper::UAC_ACCOUNTDISABLE) {
$uac &= ~LdapAttributeHelper::UAC_ACCOUNTDISABLE;
$message = __('computers.flash.enabled', ['cn' => $cn]);
$action = 'computer.enable';
} else {
$uac |= LdapAttributeHelper::UAC_ACCOUNTDISABLE;
$message = __('computers.flash.disabled', ['cn' => $cn]);
$action = 'computer.disable';
}
$computer->userAccountControl = $uac;
$computer->save();
$this->audit->log($action, [
'target_type' => 'computer', 'target' => $cn, 'target_dn' => $computer->getDn(),
'reason' => $request->input('reason') ?: null,
]);
return back()->with('status', $message);
}
/**
* Resetuje konto zaufania komputera (nowe losowe hasło konta maszyny).
* Odpowiednik "Reset Account" w ADUC - wymusza ponowne dołączenie do
* domeny przy następnym starcie, jeśli sekret jest niezgodny.
*/
public function resetTrustAccount(Request $request, string $cn): RedirectResponse
{
$computer = $this->findByCn($cn);
$randomPassword = bin2hex(random_bytes(16));
$computer->unicodepwd = iconv('UTF-8', 'UTF-16LE', '"'.$randomPassword.'"');
$computer->save();
$this->audit->log('computer.reset_trust', [
'target_type' => 'computer', 'target' => $cn, 'target_dn' => $computer->getDn(),
'reason' => $request->input('reason') ?: null,
]);
return back()->with('status', __('computers.flash.trust_reset', ['cn' => $cn]));
}
public function destroy(Request $request, string $cn): RedirectResponse
{
$computer = $this->findByCn($cn);
$dn = $computer->getDn();
$computer->delete();
$this->audit->log('computer.delete', [
'target_type' => 'computer', 'target' => $cn, 'target_dn' => $dn,
'reason' => $request->input('reason') ?: null,
]);
return back()->with('status', __('computers.flash.deleted', ['cn' => $cn]));
}
/**
* Akcje masowe (wg zaznaczonych wierszy listy komputerów).
*/
public function bulkSetEnabled(Request $request): RedirectResponse
{
$data = $request->validate([
'cns' => ['required', 'array', 'min:1'],
'cns.*' => ['string'],
'enabled' => ['nullable', 'boolean'],
'reason' => ['nullable', 'string', 'max:1000'],
]);
$enabled = (bool) ($data['enabled'] ?? false);
foreach ($data['cns'] as $cn) {
$computer = $this->findByCn($cn);
$uac = (int) ($computer->getFirstAttribute('userAccountControl') ?? 0);
if ($enabled) {
$uac &= ~LdapAttributeHelper::UAC_ACCOUNTDISABLE;
} else {
$uac |= LdapAttributeHelper::UAC_ACCOUNTDISABLE;
}
$computer->userAccountControl = $uac;
$computer->save();
}
$this->audit->log($enabled ? 'computer.bulk_enable' : 'computer.bulk_disable', [
'target_type' => 'computer',
'reason' => $data['reason'] ?? null,
'context' => ['count' => count($data['cns']), 'computers' => array_slice($data['cns'], 0, 200)],
]);
return back()->with('status', __(
$enabled ? 'computers.flash.bulk_enabled' : 'computers.flash.bulk_disabled',
['count' => count($data['cns'])],
));
}
public function bulkMove(Request $request): RedirectResponse
{
$data = $request->validate([
'cns' => ['required', 'array', 'min:1'],
'cns.*' => ['string'],
'target_ou' => ['required', 'string'],
'reason' => ['nullable', 'string', 'max:1000'],
]);
foreach ($data['cns'] as $cn) {
$this->findByCn($cn)->move($data['target_ou']);
}
$this->audit->log('computer.bulk_move', [
'target_type' => 'computer',
'reason' => $data['reason'] ?? null,
'context' => ['count' => count($data['cns']), 'target_ou' => $data['target_ou'], 'computers' => array_slice($data['cns'], 0, 200)],
]);
return back()->with('status', __('computers.flash.bulk_moved', ['count' => count($data['cns'])]));
}
protected function findByCn(string $cn): Computer
{
$computer = Computer::query()->where('cn', '=', $cn)->first();
if (! $computer) {
throw new ModelNotFoundException("Nie znaleziono komputera: {$cn}");
}
return $computer;
}
protected function presentComputer(Computer $computer): array
{
$uac = (int) ($computer->getFirstAttribute('userAccountControl') ?? 0);
$disabled = (bool) ($uac & LdapAttributeHelper::UAC_ACCOUNTDISABLE);
return [
'cn' => $computer->getFirstAttribute('cn'),
'dns_hostname' => $computer->getFirstAttribute('dnshostname'),
'os' => $computer->getFirstAttribute('operatingsystem'),
'os_version' => $computer->getFirstAttribute('operatingsystemversion'),
'dn' => $computer->getDn(),
'disabled' => $disabled,
'status_label' => $disabled ? __('common.account_status.disabled') : __('common.account_status.active'),
'status_css' => $disabled ? 'badge-status-disabled' : 'badge-status-active',
'last_logon' => LdapAttributeHelper::windowsTimestampToCarbon($computer->getFirstAttribute('lastlogontimestamp')),
];
}
}
@@ -0,0 +1,143 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
use App\Services\PanelSettingsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class ConfigController extends Controller
{
public function __construct(protected AuditLogger $audit) {}
public function index(PanelSettingsService $settings)
{
return view('admin.config.index', [
'settings' => $settings->all(),
'attributes' => $settings->attributes(),
'attributeCategories' => $settings->categories(),
'attributeTypeLabels' => PanelSettingsService::ATTRIBUTE_TYPE_LABELS,
'editableAttributeTypes' => PanelSettingsService::EDITABLE_ATTRIBUTE_TYPES,
'ldapDefaults' => [
'user_ou' => config('ldap.defaults.user_ou'),
'computer_ou' => config('ldap.defaults.computer_ou'),
'groups_ou' => config('ldap.defaults.groups_ou'),
'domain_label' => config('ldap.defaults.domain_label'),
],
'ldapConnection' => [
'hosts' => config('ldap.connections.default.hosts'),
'base_dn' => config('ldap.connections.default.base_dn'),
'encrypted' => (bool) config('ldap.connections.default.use_tls')
|| (bool) config('ldap.connections.default.use_starttls'),
'username' => config('ldap.connections.default.username'),
],
]);
}
public function update(Request $request, PanelSettingsService $settings): RedirectResponse
{
$data = $request->validate([
'reset_password.must_change_password' => ['nullable', 'boolean'],
'reset_password.unlock_account' => ['nullable', 'boolean'],
'password_policy.max_age_days' => ['required', 'integer', 'min:0', 'max:3650'],
'password_policy.generator_length' => ['nullable', 'integer', 'min:8', 'max:128'],
'password_policy.generator_symbols' => ['nullable', 'boolean'],
'encryption.key' => ['nullable', 'string', 'max:2048'],
'audit.require_reason' => ['nullable', 'boolean'],
]);
$current = $settings->all();
$current['reset_password']['must_change_password'] = (bool) ($data['reset_password']['must_change_password'] ?? false);
$current['reset_password']['unlock_account'] = (bool) ($data['reset_password']['unlock_account'] ?? false);
$current['password_policy']['max_age_days'] = (int) $data['password_policy']['max_age_days'];
$current['password_policy']['generator_length'] = (int) ($data['password_policy']['generator_length'] ?? 16);
$current['password_policy']['generator_symbols'] = (bool) ($data['password_policy']['generator_symbols'] ?? false);
$current['encryption']['key'] = trim((string) ($data['encryption']['key'] ?? ''));
$current['audit']['require_reason'] = (bool) ($data['audit']['require_reason'] ?? false);
$settings->save($current);
$this->audit->log('config.settings', [
'context' => [
'max_age_days' => $current['password_policy']['max_age_days'],
'require_reason' => $current['audit']['require_reason'],
'encryption_key_set' => $current['encryption']['key'] !== '',
],
]);
return back()->with('status', __('config.flash.saved'));
}
/**
* Zapisuje graficznie skonfigurowaną listę atrybutów AD (zakładka "Atrybuty"):
* dodanie / edycja / usunięcie, widoczność jako kolumna listy użytkowników
* oraz przypisanie do kategorii w oknie podglądu użytkownika.
*/
public function updateAttributes(Request $request, PanelSettingsService $settings): RedirectResponse
{
$validCategories = array_keys($settings->categories());
$data = $request->validate([
'attributes' => ['array'],
'attributes.*.key' => ['nullable', 'string', 'max:64', 'regex:/^[A-Za-z0-9_-]*$/'],
'attributes.*.label' => ['nullable', 'string', 'max:128'],
'attributes.*.ldap' => ['nullable', 'string', 'max:128', 'regex:/^[A-Za-z0-9-]*$/'],
'attributes.*.category' => ['nullable', 'string'],
'attributes.*.type' => ['nullable', 'string', 'in:'.implode(',', PanelSettingsService::ATTRIBUTE_TYPES)],
'attributes.*.column' => ['nullable', 'boolean'],
'attributes.*.regex' => ['nullable', 'string', 'max:191', function (string $attribute, mixed $value, \Closure $fail) {
if (is_string($value) && $value !== '' && @preg_match($value, '') === false) {
$fail(__('config.validation.regex_invalid'));
}
}],
'attributes.*.required' => ['nullable', 'boolean'],
'attributes.*.help' => ['nullable', 'string', 'max:255'],
], [
'attributes.*.key.regex' => __('config.validation.key_regex'),
'attributes.*.ldap.regex' => __('config.validation.ldap_regex'),
]);
$computedKeys = PanelSettingsService::COMPUTED_ATTRIBUTE_KEYS;
$rows = collect($data['attributes'] ?? [])
->map(function (array $row) use ($validCategories, $computedKeys) {
$key = trim((string) ($row['key'] ?? ''));
$category = $row['category'] ?? 'general';
$isComputed = in_array($key, $computedKeys, true);
return [
'key' => $key,
'label' => trim((string) ($row['label'] ?? '')),
// Kolumny wyliczane nie mają atrybutu AD - podstawiamy klucz,
// żeby przeszły przez filtr; PanelSettingsService i tak to wymusi.
'ldap' => $isComputed ? $key : trim((string) ($row['ldap'] ?? '')),
'category' => in_array($category, $validCategories, true) ? $category : 'general',
// Typ (tekst / liczba / data / tekst szyfrowany ...) - dla
// pozycji wbudowanych i tak wymusi go PanelSettingsService.
'type' => (string) ($row['type'] ?? 'text'),
// Reguły walidacji pola profilu - PanelSettingsService czyści
// je dla pozycji nieedytowalnych.
'regex' => trim((string) ($row['regex'] ?? '')),
'required' => (bool) ($row['required'] ?? false),
'help' => trim((string) ($row['help'] ?? '')),
'column' => (bool) ($row['column'] ?? false),
];
})
->filter(fn (array $row) => $row['key'] !== '' && $row['ldap'] !== '')
->values()
->all();
$settings->saveAttributes($rows);
$this->audit->log('config.attributes', [
'context' => ['count' => count($rows)],
]);
return back()
->with('status', __('config.flash.attributes_saved'))
->withFragment('attributes');
}
}
@@ -0,0 +1,349 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Ldap\User;
use App\Services\LdapAttributeHelper;
use App\Services\PanelSettingsService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use LdapRecord\Models\ActiveDirectory\Computer;
use LdapRecord\Models\ActiveDirectory\Group;
use LdapRecord\Query\Model\Builder as LdapBuilder;
class DashboardController extends Controller
{
/** Czas życia cache pulpitu (sekundy). */
protected const CACHE_TTL = 300;
/** Konfigurowalne karty raportów (pokaż/ukryj + kolejność - preferencje w cookie). */
public const REPORT_BLOCKS = [
'r_expiring', 'r_pwd_never', 'r_locked', 'r_no_manager',
'r_inactive', 'r_computers_inactive', 'r_recent',
];
/**
* Pulpit z podsumowaniem stanu domeny + krótkie raporty.
* Wynik cache'owany na 5 minut (przelicza wszystkie konta w PHP).
*/
public function index(Request $request, PanelSettingsService $settings)
{
$data = Cache::store('file')->remember(
'dashboard:summary',
self::CACHE_TTL,
fn () => $this->build($settings->passwordMaxAgeDays()),
);
$tsRaw = Cache::store('file')->get('dashboard:summary_ts');
$refreshedAt = $tsRaw ? Carbon::parse($tsRaw) : now();
Carbon::setLocale(app()->getLocale());
$data['refreshed_at'] = $refreshedAt->format('Y-m-d H:i');
$data['refreshed_at_human'] = $refreshedAt->diffForHumans();
// Dane powinny pochodzić z ostatniego cyklu cache; większy odstęp = coś
// nie przelicza się w tle (np. błąd LDAP przy odświeżaniu).
$data['refreshed_stale'] = $refreshedAt->lt(now()->subSeconds(self::CACHE_TTL + 60));
// Pusty stan globalny - LDAP nic nie zwrócił (błąd połączenia / złe OU).
$data['empty'] = ($data['stats']['users_total'] ?? 0) === 0
&& ($data['computers_total'] ?? 0) === 0
&& ($data['groups_total'] ?? 0) === 0;
// Preferencje układu pulpitu (widoczność sekcji + kolejność kart) - cookie.
$data['layout'] = $this->dashboardLayout($request);
return view('admin.dashboard.index', $data);
}
public function refresh()
{
Cache::store('file')->forget('dashboard:summary');
Cache::store('file')->forget('dashboard:summary_ts');
return redirect()->route('dashboard.index');
}
/**
* Zapisuje preferencje układu pulpitu w cookie (rok ważności).
*/
public function savePreferences(Request $request): RedirectResponse
{
$order = array_values(array_filter(
(array) $request->input('report_order', []),
fn ($k) => in_array($k, self::REPORT_BLOCKS, true),
));
// Dołóż karty pominięte w formularzu (np. dodane w nowej wersji).
foreach (self::REPORT_BLOCKS as $k) {
if (! in_array($k, $order, true)) {
$order[] = $k;
}
}
$visible = array_flip((array) $request->input('visible', []));
$payload = [
'tiles' => $request->boolean('section_tiles'),
'pwd_age' => $request->boolean('section_pwd_age'),
'reports' => array_map(
fn ($k) => ['key' => $k, 'visible' => isset($visible[$k])],
$order,
),
];
return redirect()
->route('dashboard.index')
->withCookie(cookie('dashboard_layout', json_encode($payload), 60 * 24 * 365));
}
/**
* Odczytuje preferencje układu z cookie i uzupełnia brakami z domyślnych.
*
* @return array{tiles:bool, pwd_age:bool, reports:list<array{key:string,visible:bool}>}
*/
protected function dashboardLayout(Request $request): array
{
$stored = json_decode((string) $request->cookie('dashboard_layout'), true);
$stored = is_array($stored) ? $stored : [];
$layout = [
'tiles' => ! array_key_exists('tiles', $stored) || (bool) $stored['tiles'],
'pwd_age' => ! array_key_exists('pwd_age', $stored) || (bool) $stored['pwd_age'],
'reports' => [],
];
$seen = [];
foreach ((array) ($stored['reports'] ?? []) as $r) {
$key = is_array($r) ? ($r['key'] ?? null) : null;
if (in_array($key, self::REPORT_BLOCKS, true) && ! isset($seen[$key])) {
$seen[$key] = true;
$layout['reports'][] = ['key' => $key, 'visible' => (bool) ($r['visible'] ?? true)];
}
}
foreach (self::REPORT_BLOCKS as $key) {
if (! isset($seen[$key])) {
$layout['reports'][] = ['key' => $key, 'visible' => true];
}
}
return $layout;
}
protected function build(int $maxPasswordAgeDays): array
{
$userOu = config('ldap.defaults.user_ou');
$now = now();
$cutInactive = $now->copy()->subDays(90);
$cutRecent = $now->copy()->subDays(30);
$expiryHorizon = $now->copy()->addDays(7);
$stats = [
'users_total' => 0,
'users_disabled' => 0,
'users_locked' => 0,
'pwd_never_expires' => 0,
'pwd_expiring_7d' => 0,
'pwd_expired' => 0,
'inactive_90d' => 0,
'account_expired' => 0,
];
$stats['users_no_manager'] = 0;
// Histogram wieku hasła (dni od pwdLastSet).
$pwdAge = ['lt30' => 0, 'd30_60' => 0, 'd60_90' => 0, 'gt90' => 0, 'unknown' => 0];
$expiringSoon = [];
$inactive = [];
$recent = [];
$pwdNever = [];
$locked = [];
$noManager = [];
// chunk() strumieniuje kolejne strony (paged results) - nie trzymamy w
// pamięci wszystkich obiektów naraz i liczymy KAŻDE konto w domenie.
User::query()
->in($userOu)->recursive()
->select(['cn', 'samaccountname', 'userAccountControl', 'pwdLastSet', 'lockoutTime', 'lastLogonTimestamp', 'accountExpires', 'whenCreated', 'mail', 'manager', 'directReports'])
->orderBy('cn')
->chunk(1000, function ($users) use (
&$stats, &$pwdAge, &$expiringSoon, &$inactive, &$recent, &$pwdNever, &$locked, &$noManager,
$now, $cutInactive, $cutRecent, $expiryHorizon, $maxPasswordAgeDays
) {
foreach ($users as $u) {
$stats['users_total']++;
$disabled = LdapAttributeHelper::isDisabled($u);
if ($disabled) {
$stats['users_disabled']++;
}
if (LdapAttributeHelper::isLocked($u)) {
$stats['users_locked']++;
$lockedSince = LdapAttributeHelper::windowsTimestampToCarbon($u->getFirstAttribute('lockouttime'));
$locked[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'since' => $lockedSince?->format('Y-m-d H:i') ?? '—'];
}
if (LdapAttributeHelper::passwordNeverExpires($u)) {
$stats['pwd_never_expires']++;
if (! $disabled) {
$pwdNever[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname')];
}
}
// "Dziura w org-chart": aktywne konto bez przełożonego i bez podwładnych.
if (! $disabled
&& trim((string) $u->getFirstAttribute('manager')) === ''
&& count(array_filter(array_map('trim', $u->getAttribute('directreports') ?? []))) === 0) {
$stats['users_no_manager']++;
$noManager[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname')];
}
$exp = LdapAttributeHelper::passwordExpiresAt($u, $maxPasswordAgeDays);
if ($exp) {
if ($exp->isPast()) {
$stats['pwd_expired']++;
} elseif ($exp->lte($expiryHorizon)) {
$stats['pwd_expiring_7d']++;
$expiringSoon[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'date' => $exp->format('Y-m-d')];
}
}
$lastSet = LdapAttributeHelper::passwordLastSet($u);
if (! $lastSet) {
$pwdAge['unknown']++;
} else {
$ageDays = (int) abs($lastSet->diffInDays($now));
if ($ageDays < 30) {
$pwdAge['lt30']++;
} elseif ($ageDays < 60) {
$pwdAge['d30_60']++;
} elseif ($ageDays < 90) {
$pwdAge['d60_90']++;
} else {
$pwdAge['gt90']++;
}
}
$lastLogon = LdapAttributeHelper::windowsTimestampToCarbon($u->getFirstAttribute('lastlogontimestamp'));
if (! $disabled && (! $lastLogon || $lastLogon->lt($cutInactive))) {
$stats['inactive_90d']++;
$inactive[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'last' => $lastLogon?->format('Y-m-d') ?? 'nigdy'];
}
$accExp = LdapAttributeHelper::accountExpiresAt($u);
if ($accExp && $accExp->isPast()) {
$stats['account_expired']++;
}
$created = $this->parseWhenCreated($u->getFirstAttribute('whencreated'));
if ($created && $created->gt($cutRecent)) {
$recent[] = ['cn' => $u->getFirstAttribute('cn'), 'sam' => $u->getFirstAttribute('samaccountname'), 'created' => $created->format('Y-m-d')];
}
}
});
usort($expiringSoon, fn ($a, $b) => strcmp($a['date'], $b['date']));
usort($inactive, fn ($a, $b) => strcmp($a['last'], $b['last']));
usort($recent, fn ($a, $b) => strcmp($b['created'], $a['created']));
usort($pwdNever, fn ($a, $b) => strcasecmp((string) $a['cn'], (string) $b['cn']));
usort($locked, fn ($a, $b) => strcmp($b['since'], $a['since']));
usort($noManager, fn ($a, $b) => strcasecmp((string) $a['cn'], (string) $b['cn']));
$computersInactive = $this->scanInactiveComputers($cutInactive);
Cache::store('file')->put('dashboard:summary_ts', $now->format('Y-m-d H:i:s'), self::CACHE_TTL);
$stats['computers_inactive_90d'] = $computersInactive['count'];
return [
'stats' => $stats,
'pwd_age' => $pwdAge,
'computers_total' => $this->countAll(
Computer::query()->in(config('ldap.defaults.computer_ou'))->recursive()->select(['cn'])
),
'groups_total' => $this->countAll(
Group::query()->in(config('ldap.defaults.groups_ou') ?: $userOu)->recursive()->select(['cn'])
),
'expiring_soon' => array_slice($expiringSoon, 0, 15),
'inactive' => array_slice($inactive, 0, 15),
'recent' => array_slice($recent, 0, 15),
'pwd_never_list' => array_slice($pwdNever, 0, 50),
'locked_list' => array_slice($locked, 0, 50),
'no_manager_list' => array_slice($noManager, 0, 50),
'computers_inactive' => array_slice($computersInactive['list'], 0, 50),
'max_password_age_days' => $maxPasswordAgeDays,
];
}
/**
* Skan kont komputerów: aktywne, których lastLogonTimestamp jest starszy niż
* próg (lub w ogóle brak logowania). Strumieniuje strony jak countAll().
*
* @return array{count:int, list:list<array{cn:string,last:string}>}
*/
protected function scanInactiveComputers(Carbon $cutInactive): array
{
$count = 0;
$list = [];
Computer::query()
->in(config('ldap.defaults.computer_ou'))->recursive()
->select(['cn', 'lastLogonTimestamp', 'userAccountControl'])
->orderBy('cn')
->chunk(1000, function ($computers) use (&$count, &$list, $cutInactive) {
foreach ($computers as $c) {
if ((int) ($c->getFirstAttribute('useraccountcontrol') ?? 0) & LdapAttributeHelper::UAC_ACCOUNTDISABLE) {
continue;
}
$last = LdapAttributeHelper::windowsTimestampToCarbon($c->getFirstAttribute('lastlogontimestamp'));
if ($last && $last->gte($cutInactive)) {
continue;
}
$count++;
$list[] = ['cn' => $c->getFirstAttribute('cn'), 'last' => $last?->format('Y-m-d') ?? '—'];
}
});
usort($list, fn ($a, $b) => strcmp($a['last'], $b['last']));
return ['count' => $count, 'list' => $list];
}
/**
* Liczy wszystkie obiekty pasujące do zapytania, strumieniując strony -
* bez materializowania całej listy modeli w pamięci.
*/
protected function countAll(LdapBuilder $query): int
{
$count = 0;
$query->chunk(1000, function ($rows) use (&$count) {
$count += count($rows);
});
return $count;
}
protected function parseWhenCreated(?string $value): ?Carbon
{
if (! $value) {
return null;
}
try {
return Carbon::createFromFormat('YmdHis.0Z', $value);
} catch (\Throwable) {
try {
return Carbon::parse($value);
} catch (\Throwable) {
return null;
}
}
}
}
@@ -0,0 +1,379 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use LdapRecord\Models\ActiveDirectory\Container;
use LdapRecord\Models\ActiveDirectory\Entry;
use LdapRecord\Models\ActiveDirectory\OrganizationalUnit;
class GpoController extends Controller
{
/**
* Listuje obiekty GroupPolicyContainer z kontenera CN=Policies,CN=System.
*
* UWAGA: Pełna edycja zawartości GPO (rejestr, szablony administracyjne)
* wymaga dostępu do SYSVOL po SMB i wykracza poza zakres tego panelu -
* moduł pozwala na razie na przegląd polityk oraz podgląd, do jakich OU
* są podpięte (atrybut gpLink na obiektach OU).
*/
public function index(Request $request)
{
$search = trim((string) $request->query('q', ''));
$perPage = max(10, min(200, (int) $request->query('per_page', 25)));
$baseDn = config('ldap.connections.default.base_dn');
$policiesContainer = "CN=Policies,CN=System,{$baseDn}";
$gpos = Entry::query()
->in($policiesContainer)
->whereEquals('objectclass', 'groupPolicyContainer')
->select(['cn', 'displayname', 'whencreated', 'whenchanged', 'versionnumber', 'flags'])
->get();
$linkedOuMap = $this->buildGpoLinkMap($baseDn);
$allRows = collect($gpos)->map(function (Entry $gpo) use ($linkedOuMap) {
$guid = trim((string) $gpo->getFirstAttribute('cn'), '{}');
$flags = (int) ($gpo->getFirstAttribute('flags') ?? 0);
return [
'guid' => $guid,
'name' => $gpo->getFirstAttribute('displayname') ?: $gpo->getFirstAttribute('cn'),
'created' => $this->parseLdapDate($gpo->getFirstAttribute('whencreated')),
'changed' => $this->parseLdapDate($gpo->getFirstAttribute('whenchanged')),
'version' => $gpo->getFirstAttribute('versionnumber'),
// flags: bit0 = user config disabled, bit1 = computer config disabled
'user_settings_enabled' => ! ($flags & 1),
'computer_settings_enabled' => ! ($flags & 2),
'linked_ous' => $linkedOuMap[strtolower('{'.$guid.'}')] ?? [],
];
});
if ($search !== '') {
$needle = mb_strtolower($search);
$allRows = $allRows->filter(function (array $row) use ($needle) {
return str_contains(mb_strtolower((string) $row['name']), $needle)
|| str_contains(mb_strtolower((string) $row['guid']), $needle)
|| str_contains(mb_strtolower(implode(' ', $row['linked_ous'])), $needle);
});
}
$allRows = $allRows->sortBy('name')->values();
$page = LengthAwarePaginator::resolveCurrentPage();
$gpos = new LengthAwarePaginator(
$allRows->forPage($page, $perPage)->values(),
$allRows->count(),
$perPage,
$page,
['path' => LengthAwarePaginator::resolveCurrentPath()]
);
$gpos->appends(collect($request->query())->except(['partial', 'page'])->all());
$viewData = [
'search' => $search,
'gpos' => $gpos,
'rows' => collect($gpos->items()),
];
if ($request->boolean('partial')) {
return view('admin.gpo.partials.results', $viewData);
}
return view('admin.gpo.index', $viewData);
}
/**
* Szczegóły pojedynczego GPO: wersje, stan konfiguracji, ścieżka SYSVOL,
* filtr WMI, rozszerzenia CSE oraz miejsca podpięcia (z flagą "wymuszone").
*/
public function show(string $guid): JsonResponse
{
$baseDn = config('ldap.connections.default.base_dn');
$guid = '{'.trim($guid, '{}').'}';
$gpo = Entry::query()
->in("CN=Policies,CN=System,{$baseDn}")
->whereEquals('cn', $guid)
->select(['cn', 'displayname', 'whencreated', 'whenchanged', 'versionnumber', 'flags', 'gpcfilesyspath', 'gpcwqlfilter', 'gpcmachineextensionnames', 'gpcuserextensionnames'])
->first();
if (! $gpo) {
abort(404, __('gpo.not_found'));
}
$version = (int) ($gpo->getFirstAttribute('versionnumber') ?? 0);
$flags = (int) ($gpo->getFirstAttribute('flags') ?? 0);
// versionNumber: młodsze 16 bitów = User, starsze 16 bitów = Computer.
$userVer = $version & 0xFFFF;
$computerVer = ($version >> 16) & 0xFFFF;
// Filtr WMI: gPCWQLFilter = "[domena;{GUID};0]".
$wmi = null;
if ($wql = $gpo->getFirstAttribute('gpcwqlfilter')) {
if (preg_match('/\{[0-9A-Fa-f-]+\}/', $wql, $mm)) {
$filter = Entry::query()
->in("CN=SOM,CN=WMIPolicy,CN=System,{$baseDn}")
->whereEquals('cn', $mm[0])
->select(['msp-wmi-name'])
->first();
$wmi = $filter?->getFirstAttribute('msp-wmi-name') ?? $mm[0];
} else {
$wmi = $wql;
}
}
// Podpięcia + flaga enforced dla tego GPO.
$links = [];
foreach ($this->allGpLinkSources($baseDn) as $src) {
foreach ($this->parseGpLink($src['gplink']) as $lnk) {
if (strcasecmp($lnk['guid'], $guid) === 0) {
$links[] = ['location' => $src['name'], 'enforced' => $lnk['enforced'], 'enabled' => $lnk['enabled']];
}
}
}
return response()->json([
'guid' => trim($guid, '{}'),
'name' => $gpo->getFirstAttribute('displayname') ?: $gpo->getFirstAttribute('cn'),
'created' => $this->parseLdapDate($gpo->getFirstAttribute('whencreated')),
'changed' => $this->parseLdapDate($gpo->getFirstAttribute('whenchanged')),
'version' => $version,
'user_version' => $userVer,
'computer_version' => $computerVer,
'user_settings_enabled' => ! ($flags & 1),
'computer_settings_enabled' => ! ($flags & 2),
'sysvol_path' => $gpo->getFirstAttribute('gpcfilesyspath'),
'wmi_filter' => $wmi,
'machine_cse_count' => substr_count((string) $gpo->getFirstAttribute('gpcmachineextensionnames'), '['),
'user_cse_count' => substr_count((string) $gpo->getFirstAttribute('gpcuserextensionnames'), '['),
'links' => $links,
]);
}
/**
* "Które GPO obowiązują" (RSoP-lite) dla wskazanej OU: idziemy od domeny
* w dół do wybranej OU, respektując blokowanie dziedziczenia (gPOptions
* bit 0) oraz linki wymuszone (enforced - obowiązują mimo blokady).
*/
public function rsop(Request $request): JsonResponse
{
$targetDn = trim((string) $request->query('ou', ''));
$baseDn = config('ldap.connections.default.base_dn');
if ($targetDn === '') {
return response()->json(['error' => 'Podaj OU.'], 422);
}
$names = $this->gpoNameMap($baseDn);
// Łańcuch DN od domeny w dół do wskazanej OU.
$chain = $this->dnChain($targetDn, $baseDn);
$applied = []; // finalna lista (w kolejności stosowania: później = wyższy priorytet)
$enforced = []; // linki wymuszone - dokładane na końcu (najwyższy priorytet)
$blockedFrom = null;
foreach ($chain as $depth => $node) {
$obj = $this->readObject($node['dn']);
if (! $obj) {
continue;
}
$gpOptions = (int) ($obj->getFirstAttribute('gpoptions') ?? 0);
$blockInheritance = (bool) ($gpOptions & 1);
$linkList = $this->parseGpLink($obj->getFirstAttribute('gplink') ?? '');
// Kolejność stosowania na jednym poziomie: od dołu listy do góry.
$linkList = array_reverse($linkList);
foreach ($linkList as $lnk) {
if (! $lnk['enabled']) {
continue;
}
$entry = [
'guid' => trim($lnk['guid'], '{}'),
'name' => $names[strtolower($lnk['guid'])] ?? $lnk['guid'],
'from' => $node['name'],
'enforced' => $lnk['enforced'],
];
if ($lnk['enforced']) {
$enforced[] = $entry;
} else {
$applied[] = $entry;
}
}
if ($blockInheritance && $depth < count($chain) - 1) {
// Blokada na tym poziomie: linki nieenforced z poziomów WYŻEJ
// (już zebrane) przestają obowiązywać.
$applied = [];
$blockedFrom = $node['name'];
}
}
return response()->json([
'target' => $targetDn,
'blocked_inheritance_at' => $blockedFrom,
'effective' => array_merge($applied, $enforced),
]);
}
/**
* @return list<array{dn:string,name:string}> od domeny w dół do $targetDn
*/
protected function dnChain(string $targetDn, string $baseDn): array
{
$parts = array_map('trim', explode(',', $targetDn));
$chain = [];
for ($i = 0; $i < count($parts); $i++) {
$dn = implode(',', array_slice($parts, $i));
if (! str_contains(strtolower($dn), 'ou=') && strcasecmp($dn, $baseDn) !== 0) {
continue;
}
[$rdnKey, $rdnVal] = array_pad(explode('=', $parts[$i], 2), 2, '');
$chain[] = ['dn' => $dn, 'name' => strcasecmp($dn, $baseDn) === 0 ? 'Domena' : $rdnVal];
}
return array_reverse($chain); // domena najpierw
}
protected function readObject(string $dn): ?Entry
{
try {
return Entry::query()->select(['gplink', 'gpoptions'])->find($dn);
} catch (\Throwable) {
return null;
}
}
/**
* @return list<array{name:string,gplink:string}>
*/
protected function allGpLinkSources(string $baseDn): array
{
$out = [];
foreach (OrganizationalUnit::query()->select(['ou', 'distinguishedname', 'gplink'])->paginate(500) as $ou) {
if ($gp = $ou->getFirstAttribute('gplink')) {
$out[] = ['name' => $ou->getFirstAttribute('ou') ?? $ou->getDn(), 'gplink' => $gp];
}
}
$root = Container::query()->find($baseDn);
if ($root && ($gp = $root->getFirstAttribute('gplink'))) {
$out[] = ['name' => __('gpo.domain_root'), 'gplink' => $gp];
}
return $out;
}
/**
* @return array<string,string> strtolower({GUID}) => displayName
*/
protected function gpoNameMap(string $baseDn): array
{
$map = [];
foreach (Entry::query()->in("CN=Policies,CN=System,{$baseDn}")->whereEquals('objectclass', 'groupPolicyContainer')->select(['cn', 'displayname'])->paginate(500) as $g) {
$map[strtolower((string) $g->getFirstAttribute('cn'))] = $g->getFirstAttribute('displayname') ?: $g->getFirstAttribute('cn');
}
return $map;
}
/**
* Parsuje gpLink na listę linków z flagami.
*
* @return list<array{guid:string,enforced:bool,enabled:bool}>
*/
protected function parseGpLink(string $gpLink): array
{
preg_match_all('/\[LDAP:\/\/(?:cn=)?(\{[0-9A-Fa-f-]+\})[^;]*;\s*(\d+)\]/i', $gpLink, $m, PREG_SET_ORDER);
return array_map(function ($x) {
$opt = (int) $x[2];
return [
'guid' => $x[1],
'enabled' => ! ($opt & 1), // bit0 = link disabled
'enforced' => (bool) ($opt & 2), // bit1 = enforced
];
}, $m);
}
/**
* Buduje mapę GUID GPO => lista obiektów (OU / Domena), do których jest podpięte,
* na podstawie atrybutu gpLink obecnego na obiektach OU oraz w korzeniu domeny.
*
* Format gpLink: [LDAP://cn={GUID},cn=policies,cn=system,DC=...;<opcje>][...]
*/
protected function buildGpoLinkMap(string $baseDn): array
{
$map = [];
// 1. Pobierz podpięcia z obiektów OrganizationalUnit
$ous = OrganizationalUnit::query()
->select(['ou', 'distinguishedname', 'gplink'])
->get();
foreach ($ous as $ou) {
$gpLink = $ou->getFirstAttribute('gplink');
if ($gpLink) {
$ouName = $ou->getFirstAttribute('ou') ?? $ou->getDn();
$this->extractGuidsFromGpLink($gpLink, $ouName, $map);
}
}
// 2. Pobierz podpięcia bezpośrednio z korzenia domeny (Domain Root)
$domainRoot = Container::query()->find($baseDn);
if ($domainRoot) {
$gpLink = $domainRoot->getFirstAttribute('gplink');
if ($gpLink) {
$this->extractGuidsFromGpLink($gpLink, __('gpo.domain_root'), $map);
}
}
return $map;
}
/**
* Parsuje ciąg gpLink i dodaje odnalezione dopasowania do mapy.
*/
protected function extractGuidsFromGpLink(string $gpLink, string $locationName, array &$map): void
{
preg_match_all('/\[LDAP:\/\/cn=(\{[0-9A-Fa-f-]+\}),/i', $gpLink, $matches);
foreach ($matches[1] ?? [] as $guid) {
$key = strtolower($guid);
if (! isset($map[$key])) {
$map[$key] = [];
}
if (! in_array($locationName, $map[$key], true)) {
$map[$key][] = $locationName;
}
}
}
/**
* Konwertuje ciąg daty z LDAP (np. 20231025120000.0Z) na obiekt Carbon/string.
*/
protected function parseLdapDate(?string $ldapDate): ?string
{
if (! $ldapDate) {
return null;
}
try {
return Carbon::createFromFormat('YmdHis.0Z', $ldapDate)->format('Y-m-d H:i');
} catch (\Throwable $e) {
return $ldapDate;
}
}
}
@@ -0,0 +1,331 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
use App\Services\OuTreeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use LdapRecord\Models\ActiveDirectory\Entry;
use LdapRecord\Models\ActiveDirectory\Group;
use LdapRecord\Models\ActiveDirectory\User;
use LdapRecord\Models\ModelNotFoundException;
class GroupController extends Controller
{
// groupType (bity) - patrz [MS-ADTS] 2.2.13.
protected const TYPE_SECURITY = 0x80000000;
protected const SCOPE_GLOBAL = 0x00000002;
protected const SCOPE_DOMAIN_LOCAL = 0x00000004;
protected const SCOPE_UNIVERSAL = 0x00000008;
public function __construct(protected AuditLogger $audit) {}
public function index(Request $request, OuTreeService $ouTree)
{
if ($request->boolean('refresh_ou')) {
OuTreeService::flush();
}
$allOu = $request->boolean('all_ou');
$currentOu = $request->query('ou') ?: ($allOu ? null : $ouTree->rootDn('groups'));
$search = trim((string) $request->query('q', ''));
$type = $request->query('type'); // security|distribution
$scope = $request->query('scope'); // global|domain_local|universal
$perPage = max(10, min(200, (int) $request->query('per_page', 25)));
$query = Group::query();
if ($currentOu) {
$query->in($currentOu)->recursive();
}
if ($search !== '') {
$searchAttrs = ['cn', 'description', 'mail', 'samaccountname', 'info'];
$query->orFilter(function ($q) use ($searchAttrs, $search) {
foreach ($searchAttrs as $attr) {
$q->whereContains($attr, $search);
}
});
}
// 1. Pobieramy dopasowane grupy z LDAP (paged results control).
$allGroups = $query
->select(['cn', 'description', 'mail', 'grouptype', 'member', 'distinguishedname'])
->orderBy('cn')
->paginate(500);
// 2. Mapujemy na DTO/Tablicę i filtrujemy według typu i zasięgu przed stronnicowaniem
$allRows = collect($allGroups)->map(fn (Group $g) => $this->presentGroup($g))
->when($type, fn ($rows) => $rows->where('type_key', $type))
->when($scope, fn ($rows) => $rows->where('scope_key', $scope))
->values();
// 3. Budujemy natywny LengthAwarePaginator Laravela
$page = LengthAwarePaginator::resolveCurrentPage();
$total = $allRows->count();
$groups = new LengthAwarePaginator(
$allRows->forPage($page, $perPage)->values(),
$total,
$perPage,
$page,
['path' => LengthAwarePaginator::resolveCurrentPath()]
);
$groups->appends(collect($request->query())->except(['partial', 'page'])->all());
$viewData = [
'currentOu' => $currentOu,
'allOu' => $allOu,
'search' => $search,
'typeFilter' => $type,
'scopeFilter' => $scope,
'groups' => $groups,
'rows' => collect($groups->items()),
];
if ($request->boolean('partial')) {
return view('admin.groups.partials.results', $viewData);
}
return view('admin.groups.index', $viewData + [
'ouTree' => $ouTree->tree('groups'),
]);
}
/**
* Podpowiedzi grup (typeahead) - do dodawania członkostwa z profilu użytkownika.
*/
public function search(Request $request): JsonResponse
{
$q = trim((string) $request->query('q', ''));
if (mb_strlen($q) < 2) {
return response()->json([]);
}
$results = Group::query()
->in(config('ldap.defaults.groups_ou') ?: config('ldap.defaults.user_ou'))->recursive()
->select(['cn', 'description'])
->orFilter(function ($sub) use ($q) {
foreach (['cn', 'samaccountname', 'description'] as $attr) {
$sub->whereContains($attr, $q);
}
})
->orderBy('cn')
->limit(25)
->paginate(25);
return response()->json(
collect($results)->take(25)->map(fn (Group $g) => [
'dn' => $g->getDn(),
'cn' => $g->getFirstAttribute('cn'),
'description' => $g->getFirstAttribute('description'),
])->values()
);
}
public function show(string $cn): JsonResponse
{
$group = $this->findByCn($cn);
$members = collect($group->members()->get())->map(function ($member) {
return [
'name' => $member->getFirstAttribute('cn'),
'samaccountname' => $member->getFirstAttribute('samaccountname'),
'dn' => $member->getDn(),
];
})->values();
return response()->json([
'cn' => $group->getFirstAttribute('cn'),
'description' => $group->getFirstAttribute('description'),
'mail' => $group->getFirstAttribute('mail'),
'dn' => $group->getDn(),
'members' => $members,
]);
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'cn' => ['required', 'string', 'max:64'],
'description' => ['nullable', 'string', 'max:255'],
'ou' => ['required', 'string'],
'group_type' => ['required', 'in:security,distribution'],
'group_scope' => ['required', 'in:global,domain_local,universal'],
]);
$groupType = $this->groupTypeValue($data['group_type'], $data['group_scope']);
$group = new Group;
$group->inside($data['ou']);
$group->cn = $data['cn'];
$group->samaccountname = $data['cn'];
$group->description = $data['description'] ?? null;
$group->grouptype = $groupType;
$group->save();
$this->audit->log('group.create', [
'target_type' => 'group', 'target' => $data['cn'], 'target_dn' => $group->getDn(),
'context' => ['ou' => $data['ou'], 'type' => $data['group_type'], 'scope' => $data['group_scope']],
]);
return back()->with('status', __('groups.flash.created', ['cn' => $data['cn']]));
}
public function update(Request $request, string $cn): RedirectResponse
{
$data = $request->validate([
'description' => ['nullable', 'string', 'max:255'],
'mail' => ['nullable', 'email'],
]);
$group = $this->findByCn($cn);
$changes = [];
foreach (['description' => 'description', 'mail' => 'mail'] as $field => $attr) {
$old = $group->getFirstAttribute($attr);
$new = $data[$field] ?? null;
if ((string) ($old ?? '') !== (string) ($new ?? '')) {
$changes[$field] = [$old, $new];
}
}
$group->description = $data['description'] ?? null;
$group->mail = $data['mail'] ?? null;
$group->save();
$this->audit->log('group.update', [
'target_type' => 'group', 'target' => $cn, 'target_dn' => $group->getDn(),
'changes' => $changes ?: null,
]);
return back()->with('status', __('groups.flash.updated'));
}
public function addMember(Request $request, string $cn): RedirectResponse
{
$data = $request->validate([
'samaccountname' => ['required', 'string'],
]);
$group = $this->findByCn($cn);
$user = User::query()->where('samaccountname', '=', $data['samaccountname'])->firstOrFail();
$group->members()->attach($user);
$this->audit->log('group.member_add', [
'target_type' => 'group', 'target' => $cn, 'target_dn' => $group->getDn(),
'context' => ['member' => $data['samaccountname']],
]);
return back()->with('status', __('groups.flash.member_added'));
}
public function removeMember(Request $request, string $cn): RedirectResponse
{
$data = $request->validate([
'member_dn' => ['required', 'string'],
'reason' => ['nullable', 'string', 'max:1000'],
]);
$group = $this->findByCn($cn);
$member = Entry::query()->findByDn($data['member_dn']);
if ($member) {
$group->members()->detach($member);
}
$this->audit->log('group.member_remove', [
'target_type' => 'group', 'target' => $cn, 'target_dn' => $group->getDn(),
'reason' => $data['reason'] ?? null,
'context' => ['member_dn' => $data['member_dn']],
]);
return back()->with('status', __('groups.flash.member_removed'));
}
public function destroy(Request $request, string $cn): RedirectResponse
{
$data = $request->validate(['reason' => ['nullable', 'string', 'max:1000']]);
$group = $this->findByCn($cn);
$dn = $group->getDn();
$group->delete();
$this->audit->log('group.delete', [
'target_type' => 'group', 'target' => $cn, 'target_dn' => $dn,
'reason' => $data['reason'] ?? null,
]);
return back()->with('status', __('groups.flash.deleted', ['cn' => $cn]));
}
protected function findByCn(string $cn): Group
{
$group = Group::query()->where('cn', '=', $cn)->first();
if (! $group) {
throw new ModelNotFoundException("Nie znaleziono grupy: {$cn}");
}
return $group;
}
protected function groupTypeValue(string $type, string $scope): int
{
$value = match ($scope) {
'global' => self::SCOPE_GLOBAL,
'domain_local' => self::SCOPE_DOMAIN_LOCAL,
'universal' => self::SCOPE_UNIVERSAL,
};
if ($type === 'security') {
// Bitowa suma jako liczba całkowita ze znakiem (32-bit) - AD
// przechowuje groupType jako wartość ujemną dla grup bezpieczeństwa.
$value = $value | self::TYPE_SECURITY;
$value = $value > 0x7FFFFFFF ? $value - 0x100000000 : $value;
}
return $value;
}
protected function presentGroup(Group $group): array
{
$groupType = (int) ($group->getFirstAttribute('grouptype') ?? 0);
$isSecurity = ($groupType & self::TYPE_SECURITY) !== 0 || $groupType < 0;
$scopeKey = match (true) {
(abs($groupType) & self::SCOPE_DOMAIN_LOCAL) !== 0 => 'domain_local',
(abs($groupType) & self::SCOPE_UNIVERSAL) !== 0 => 'universal',
default => 'global',
};
$scopeLabel = match ($scopeKey) {
'domain_local' => 'Domain Local',
'universal' => 'Universal',
default => 'Global',
};
$memberCount = count($group->getAttribute('member') ?? []);
return [
'cn' => $group->getFirstAttribute('cn'),
'description' => $group->getFirstAttribute('description'),
'mail' => $group->getFirstAttribute('mail'),
'dn' => $group->getDn(),
'member_count' => $memberCount,
'type_key' => $isSecurity ? 'security' : 'distribution',
'type_label' => $isSecurity ? 'Security' : 'Distribution',
'scope_key' => $scopeKey,
'scope_label' => $scopeLabel,
];
}
}
@@ -0,0 +1,152 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Ldap\User;
use App\Services\LdapAttributeHelper;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
class OrgChartController extends Controller
{
/**
* Graficzna struktura organizacyjna z relacji manager -> directReports.
*
* Do wykresu trafiają WYŁĄCZNIE aktywne konta, które faktycznie są w
* hierarchii: mają przypisanego przełożonego LUB są czyimś przełożonym.
* Konta wyłączone oraz konta bez żadnej relacji są pomijane całkowicie.
*
* Cały zbiór pobieramy jednym zapytaniem (paged control) i budujemy drzewo
* w PHP. Wynik w cache pliku na 10 minut ("Przelicz" czyści cache).
*/
public function index(): View
{
$data = Cache::store('file')->remember('org_chart:v3', 600, fn () => $this->build());
return view('admin.org.index', [
'flat' => $data['flat'],
'stats' => $data['stats'],
'refreshed_at' => Cache::store('file')->get('org_chart:v3_ts', now()->format('Y-m-d H:i')),
]);
}
public function refresh()
{
Cache::store('file')->forget('org_chart:v3');
Cache::store('file')->forget('org_chart:v3_ts');
return redirect()->route('org.index');
}
/**
* Buduje PŁASKĄ listę węzłów (id / parentId) dla biblioteki d3-org-chart.
* Jeden syntetyczny korzeń "__root__" spina osoby bez przełożonego.
*
* @return array{flat: list<array<string,mixed>>, stats: array<string,int>}
*/
protected function build(): array
{
$users = User::query()
->in(config('ldap.defaults.user_ou'))->recursive()
->select(['cn', 'samaccountname', 'title', 'department', 'manager', 'directReports', 'userAccountControl', 'distinguishedName'])
->orderBy('cn')
->paginate(500);
// Węzły kwalifikujące się do wykresu: aktywne + faktycznie w hierarchii.
$byDn = []; // dn(lower) => sam
$nodes = []; // sam => dane węzła
foreach ($users as $u) {
$uac = (int) ($u->getFirstAttribute('useraccountcontrol') ?? 0);
if ($uac & LdapAttributeHelper::UAC_ACCOUNTDISABLE) {
continue; // tylko aktywni
}
$managerDn = trim((string) $u->getFirstAttribute('manager'));
$reports = array_filter(array_map('trim', $u->getAttribute('directreports') ?? []));
if ($managerDn === '' && count($reports) === 0) {
continue; // poza hierarchią - nie pokazujemy wcale
}
$sam = $u->getFirstAttribute('samaccountname');
$byDn[strtolower(trim((string) $u->getDn()))] = $sam;
$nodes[$sam] = [
'sam' => $sam,
'cn' => $u->getFirstAttribute('cn') ?: $sam,
'title' => $u->getFirstAttribute('title'),
'department' => $u->getFirstAttribute('department'),
'manager_dn' => $managerDn !== '' ? strtolower($managerDn) : null,
];
}
// parentId = sam przełożonego, jeśli jest w zbiorze; inaczej korzeń.
$parentOf = [];
foreach ($nodes as $sam => $n) {
$pdn = $n['manager_dn'];
$parentOf[$sam] = ($pdn && isset($byDn[$pdn]) && $byDn[$pdn] !== $sam)
? $byDn[$pdn]
: '__root__';
}
// Rozbijanie cykli manager -> ... -> ten sam (d3-org-chart tego nie zniesie).
foreach (array_keys($parentOf) as $sam) {
$seen = [$sam => true];
$cur = $parentOf[$sam];
$guard = 0;
while ($cur !== '__root__' && $guard++ < 100) {
if (isset($seen[$cur])) {
$parentOf[$sam] = '__root__';
break;
}
$seen[$cur] = true;
$cur = $parentOf[$cur] ?? '__root__';
}
}
$childCount = array_count_values(array_values($parentOf)); // parent sam => liczba dzieci
$flat = [[
'id' => '__root__',
'parentId' => '',
'name' => config('ldap.defaults.domain_label'),
'title' => __('org.top_level'),
'department' => null,
'sam' => null,
'root' => true,
]];
$cmp = fn ($a, $b) => strcasecmp((string) $a['name'], (string) $b['name']);
$people = [];
foreach ($nodes as $sam => $n) {
$people[] = [
'id' => $sam,
'parentId' => $parentOf[$sam],
'name' => $n['cn'],
'title' => $n['title'],
'department' => $n['department'],
'sam' => $sam,
];
}
usort($people, $cmp);
$flat = array_merge($flat, $people);
$managers = 0;
foreach ($childCount as $parentSam => $count) {
if ($parentSam !== '__root__' && $count > 0) {
$managers++;
}
}
Cache::store('file')->put('org_chart:v3_ts', now()->format('Y-m-d H:i'), 600);
return [
'flat' => $flat,
'stats' => [
'people' => count($nodes),
'managers' => $managers,
'top' => $childCount['__root__'] ?? 0,
],
];
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use LdapRecord\LdapRecordException;
/**
* Logowanie do panelu kontem Active Directory.
*
* Guard 'ldap' (LdapRecord "plain bind") sprawdza login + hasło bindem do DC,
* a dostęp do panelu wymusza reguła MemberOfLoginGroup (członkostwo w grupie
* z AD_LOGIN_GROUP). Nie prowadzimy lokalnej tabeli users - po zalogowaniu
* profil admina ląduje w sesji pod kluczem 'ad_admin'.
*/
class LoginController extends Controller
{
/**
* Maksymalna liczba nieudanych prób logowania (login + IP) zanim
* zadziała throttling.
*/
private const MAX_ATTEMPTS = 5;
public function show(): View
{
return view('auth.login');
}
public function login(Request $request): RedirectResponse
{
$credentials = $request->validate([
'login' => ['required', 'string', 'max:255'],
'password' => ['required', 'string'],
], attributes: [
'login' => __('auth.attributes.login'),
'password' => __('auth.attributes.password'),
]);
$this->ensureIsNotRateLimited($request);
try {
$authenticated = Auth::guard('ldap')->attempt([
'samaccountname' => $credentials['login'],
'password' => $credentials['password'],
]);
} catch (LdapRecordException $e) {
report($e);
throw ValidationException::withMessages([
'login' => __('auth.failed_dc'),
]);
}
if (! $authenticated) {
RateLimiter::hit($this->throttleKey($request));
throw ValidationException::withMessages([
'login' => __('auth.failed_credentials'),
]);
}
RateLimiter::clear($this->throttleKey($request));
$request->session()->regenerate();
$request->session()->put(
'ad_admin',
Auth::guard('ldap')->user()->toPanelProfile()
);
return redirect()->route('dashboard.index');
}
public function logout(Request $request): RedirectResponse
{
Auth::guard('ldap')->logout();
$request->session()->forget('ad_admin');
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->with('status', __('auth.logged_out'));
}
/**
* @throws ValidationException
*/
protected function ensureIsNotRateLimited(Request $request): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), self::MAX_ATTEMPTS)) {
return;
}
$seconds = RateLimiter::availableIn($this->throttleKey($request));
throw ValidationException::withMessages([
'login' => __('auth.throttled', ['seconds' => $seconds]),
]);
}
protected function throttleKey(Request $request): string
{
return Str::transliterate(
Str::lower((string) $request->input('login')).'|'.$request->ip()
);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Cookie;
/**
* Zmiana języka interfejsu. Działa też przed zalogowaniem (ekran logowania).
* Wybór zapisujemy w sesji oraz w rocznym cookie, żeby przetrwał wylogowanie.
*/
class LocaleController extends Controller
{
public function switch(Request $request, string $locale): RedirectResponse
{
abort_unless(array_key_exists($locale, config('locales', [])), 404);
$request->session()->put('locale', $locale);
return redirect()
->back()
->withCookie(cookie('locale', $locale, 60 * 24 * 365, sameSite: Cookie::SAMESITE_LAX));
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Contracts\View\View;
/**
* Strony informacyjne panelu (dostępne także bez logowania).
*/
class PageController extends Controller
{
/**
* /o-aplikacji - O projekcie, Autor, Kwestie prawne (licencja MIT, RODO,
* brak powiązania z Microsoft).
*/
public function about(): View
{
return view('pages.about');
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
/**
* Ustawia język aplikacji na podstawie wyboru użytkownika.
*
* Kolejność źródeł: sesja ('locale') -> cookie ('locale') -> domyślny
* z config('app.locale'). Wartość musi być jednym z config('locales'),
* w przeciwnym razie jest ignorowana (zostaje język domyślny).
*/
class SetLocale
{
public function handle(Request $request, Closure $next): Response
{
$supported = array_keys(config('locales', []));
$locale = $request->session()->get('locale')
?? $request->cookie('locale')
?? config('app.locale');
if (in_array($locale, $supported, true)) {
App::setLocale($locale);
// Utrwal wybór w sesji, gdy przyszedł tylko z cookie.
$request->session()->put('locale', $locale);
}
return $next($request);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Ldap\Rules;
use Illuminate\Database\Eloquent\Model as Eloquent;
use LdapRecord\Laravel\Auth\Rule;
use LdapRecord\Models\Model as LdapRecord;
/**
* Reguła autoryzacji logowania do panelu.
*
* Po poprawnym bindzie (login + hasło) LdapRecord sprawdza jeszcze tę regułę.
* Do panelu wpuszczamy wyłącznie członków grupy z config('ldap.auth.login_group')
* (wartość z AD_LOGIN_GROUP). Grupę można podać jako pełny DN (zalecane) lub samą
* nazwę (CN). Sprawdzane jest członkostwo:
* - bezpośrednie (atrybut memberOf),
* - zagnieżdżone (grupa w grupie) oraz grupa podstawowa - przez relację groups().
*
* Brak skonfigurowanej grupy => nikt się nie zaloguje (fail-closed).
*/
class MemberOfLoginGroup implements Rule
{
public function passes(LdapRecord $user, ?Eloquent $model = null): bool
{
$required = trim((string) config('ldap.auth.login_group'));
if ($required === '') {
return false;
}
$isDn = str_contains($required, '=');
$needle = $isDn ? $this->normalizeDn($required) : $this->normalizeCn($required);
// Szybka ścieżka: bezpośrednie członkostwo z atrybutu memberOf.
foreach ((array) $user->getAttribute('memberof') as $dn) {
if ($this->matches((string) $dn, $needle, $isDn)) {
return true;
}
}
// Pełna ścieżka: członkostwo zagnieżdżone + grupa podstawowa.
foreach ($this->nestedGroupDns($user) as $dn) {
if ($this->matches($dn, $needle, $isDn)) {
return true;
}
}
return false;
}
/**
* DN wszystkich grup użytkownika z uwzględnieniem zagnieżdżenia.
*
* @return list<string>
*/
protected function nestedGroupDns(LdapRecord $user): array
{
if (! method_exists($user, 'groups')) {
return [];
}
return $user->groups()->recursive()->get(['cn'])
->map(fn ($group) => (string) $group->getDn())
->all();
}
private function matches(string $groupDn, string $needle, bool $isDn): bool
{
if ($groupDn === '') {
return false;
}
return $isDn
? $this->normalizeDn($groupDn) === $needle
: $this->normalizeCn($this->firstRdn($groupDn)) === $needle;
}
private function firstRdn(string $dn): string
{
return explode(',', $dn, 2)[0];
}
private function normalizeCn(string $value): string
{
$value = trim($value);
if (stripos($value, 'cn=') === 0) {
$value = substr($value, 3);
}
return mb_strtolower(trim($value));
}
private function normalizeDn(string $dn): string
{
return mb_strtolower(preg_replace('/\s*,\s*/', ',', trim($dn)));
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
namespace App\Ldap;
use App\Services\PanelSettingsService;
use LdapRecord\Models\ActiveDirectory\User as ActiveDirectoryUser;
/**
* Model użytkownika Active Directory używany przez panel.
*
* Mapowanie pól panelu na atrybuty AD nie jest już zaszyte w kodzie -
* pochodzi z konfiguracji panelu (zakładka "Atrybuty"), obsługiwanej przez
* {@see PanelSettingsService}. Ta klasa jest cienką nakładką, która z tej
* konfiguracji buduje:
* - listę atrybutów pobieranych z LDAP (patrz panelAttributes()),
* - mapę wartości do odpowiedzi JSON w UserController@show (mappedAttributes()),
* - listę pól edytowalnych używaną przy zapisie (editableAttributes()).
*/
class User extends ActiveDirectoryUser
{
/**
* Pełne definicje atrybutów z konfiguracji panelu.
*
* @return list<array<string, mixed>>
*/
public static function attributeDefinitions(): array
{
return app(PanelSettingsService::class)->attributes();
}
/**
* Mapa: klucz pola panelu => nazwa atrybutu AD.
*
* @return array<string, string>
*/
public static function attributeMap(): array
{
$map = [];
foreach (self::attributeDefinitions() as $attr) {
if (! empty($attr['computed'])) {
continue; // kolumny wyliczane nie są atrybutami AD
}
if (! empty($attr['fixed'])) {
continue; // np. accountExpires - odczyt / zapis ma dedykowaną obsługę
}
if ($attr['key'] !== '' && $attr['ldap'] !== '') {
$map[$attr['key']] = $attr['ldap'];
}
}
return $map;
}
/**
* Mapa pól, które panel pozwala edytować w formularzu profilu.
* Klucz = nazwa pola formularza, wartość = nazwa atrybutu AD w formie
* akceptowanej przez LdapRecord przy zapisie (małe litery).
*
* @return array<string, string>
*/
public static function editableAttributes(): array
{
$map = [];
foreach (self::attributeDefinitions() as $attr) {
if (! empty($attr['editable']) && $attr['key'] !== '' && $attr['ldap'] !== '') {
$map[$attr['key']] = strtolower($attr['ldap']);
}
}
return $map;
}
/**
* Wszystkie atrybuty AD, które panel powinien pobrać dla użytkownika:
* wartości z mapy atrybutów + atrybuty techniczne potrzebne do wyliczenia
* statusu konta, dat haseł i członkostwa w grupach.
*
* @return list<string>
*/
public static function panelAttributes(): array
{
return array_values(array_unique(array_merge(
array_values(self::attributeMap()),
[
'cn',
'userPrincipalName',
'userAccountControl',
'pwdLastSet',
'lockoutTime',
'lastLogonTimestamp',
'memberOf',
'accountExpires',
],
)));
}
/**
* Zwraca wartości atrybutów z mapy jako płaską tablicę kluczowaną
* kluczami mapy (np. 'firstName', 'employeeID', 'google').
*
* @return array<string, string|null>
*/
public function mappedAttributes(): array
{
$values = [];
foreach (self::attributeMap() as $key => $ldapAttribute) {
$values[$key] = $this->getFirstAttribute(strtolower($ldapAttribute));
}
return $values;
}
/**
* Lekki profil zalogowanego administratora trzymany w sesji pod kluczem
* 'ad_admin' (zgodnie z konwencją projektu - patrz config/ldap.php).
* Buduje się na statycznym mapowaniu config('ldap.attributes.user').
*
* @return array{login: ?string, display_name: string, mail: ?string, dn: string}
*/
public function toPanelProfile(): array
{
$map = config('ldap.attributes.user', []);
$get = fn (string $key, string $fallback): ?string => $this->getFirstAttribute($map[$key] ?? $fallback);
$login = $get('login', 'samaccountname');
$display = $get('display_name', 'displayname')
?: trim(($get('first_name', 'givenname') ?? '').' '.($get('last_name', 'sn') ?? ''))
?: $this->getFirstAttribute('cn');
return [
'login' => $login,
'display_name' => $display ?: ($login ?? 'Administrator'),
'mail' => $get('mail', 'mail'),
'dn' => (string) $this->getDn(),
];
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* Jeden wpis dziennika zmian panelu (tabela `panel_audit`).
* Zapis wyłącznie przez App\Services\AuditLogger; odczyt w AuditController.
*
* @property int $id
* @property string $actor
* @property string $actor_name
* @property string $action
* @property string|null $target_type
* @property string|null $target
* @property string|null $target_dn
* @property string|null $reason
* @property array|null $changes
* @property array|null $context
* @property string|null $ip
* @property Carbon|null $created_at
*/
class AuditLog extends Model
{
protected $table = 'panel_audit';
public $timestamps = false;
protected $fillable = [
'actor', 'actor_name', 'action', 'target_type', 'target',
'target_dn', 'reason', 'changes', 'context', 'ip', 'created_at',
];
protected $casts = [
'changes' => 'array',
'context' => 'array',
'created_at' => 'datetime',
];
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Jedna definicja atrybutu AD w panelu: kolumna listy użytkowników i/lub pole
* w oknie "Podgląd / Edycja". Kolejność wg `position`. Odczyt i zapis przez
* App\Services\PanelSettingsService (nie czytaj tej tabeli wprost z kontrolerów).
*
* @property int $id
* @property string $key
* @property string $label
* @property string $ldap
* @property string $category
* @property string $type
* @property string|null $regex
* @property bool $required
* @property string $help
* @property bool $is_column
* @property int $position
*/
class PanelAttribute extends Model
{
protected $table = 'panel_attributes';
protected $fillable = ['key', 'label', 'ldap', 'category', 'type', 'regex', 'required', 'help', 'is_column', 'position'];
protected $casts = [
'required' => 'boolean',
'is_column' => 'boolean',
'position' => 'integer',
];
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Pojedynczy wpis konfiguracji panelu (klucz -> wartość JSON).
* Jedyny obecnie używany klucz to "panel" - trzyma całą strukturę ustawień
* (patrz App\Services\PanelSettingsService).
*
* @property string $key
* @property array<string, mixed>|null $value
*/
class PanelSetting extends Model
{
protected $table = 'panel_settings';
protected $primaryKey = 'key';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['key', 'value'];
protected $casts = [
'value' => 'array',
];
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Providers;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Paginator::useBootstrapFive();
if (config('app.env') !== 'local' || request()->server('HTTP_X_FORWARDED_PROTO') === 'https') {
URL::forceScheme('https');
}
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
namespace App\Services;
use Illuminate\Encryption\Encrypter;
/**
* Szyfrowanie / odszyfrowanie wartości atrybutów oznaczonych w konfiguracji
* panelu jako "szyfrowany" (np. msDS-cloudExtensionAttribute1/2).
*
* Format szyfrogramu jest zgodny z Laravel Crypt (AES-CBC + MAC), dzięki
* czemu panel odczyta wartości zaszyfrowane wcześniej wartością APP_KEY.
* Klucz z konfiguracji panelu:
* - "base64:...." dekodujące się do 16 lub 32 bajtów -> użyty wprost
* (tu wklej wartość APP_KEY, jeśli atrybuty były nią szyfrowane),
* - dowolny inny tekst (hasło) -> klucz = SHA-256 z tego tekstu (32 B).
* Puste = szyfrowanie wyłączone (wartości przechodzą bez zmian).
*/
class AttributeCipher
{
protected ?Encrypter $encrypter = null;
public function __construct(protected PanelSettingsService $settings) {}
public function isConfigured(): bool
{
return $this->settings->encryptionKey() !== '';
}
protected function keyBytes(): string
{
$key = $this->settings->encryptionKey();
if (str_starts_with($key, 'base64:')) {
$decoded = base64_decode(substr($key, 7), true);
if ($decoded !== false && in_array(strlen($decoded), [16, 32], true)) {
return $decoded;
}
}
return hash('sha256', $key, true); // 32 bajty
}
protected function encrypter(): Encrypter
{
if ($this->encrypter === null) {
$key = $this->keyBytes();
$this->encrypter = new Encrypter($key, strlen($key) === 16 ? 'aes-128-cbc' : 'aes-256-cbc');
}
return $this->encrypter;
}
/**
* Zwraca odszyfrowaną wartość. Jeśli szyfrowanie jest wyłączone albo
* wartość nie jest naszym szyfrogramem (np. zły klucz, dane wpisane
* ręcznie) - zwraca ją bez zmian.
*/
public function decrypt(?string $value): ?string
{
if ($value === null || $value === '' || ! $this->isConfigured()) {
return $value;
}
try {
return $this->encrypter()->decryptString($value);
} catch (\Throwable) {
return $value;
}
}
/**
* Zwraca zaszyfrowaną wartość. Jeśli szyfrowanie jest wyłączone - zwraca
* bez zmian. Jeśli wartość jest już naszym szyfrogramem - nie szyfruje
* jej ponownie.
*/
public function encrypt(?string $value): ?string
{
if ($value === null || $value === '' || ! $this->isConfigured()) {
return $value;
}
try {
$this->encrypter()->decryptString($value);
return $value; // już zaszyfrowane naszym schematem
} catch (\Throwable) {
// wartość jawna - szyfrujemy
}
return $this->encrypter()->encryptString($value);
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use Illuminate\Support\Facades\Auth;
/**
* Zapis wpisów do dziennika zmian panelu (`panel_audit`).
*
* Nigdy nie przerywa operacji AD - błąd zapisu logu jest tylko raportowany.
* Tożsamość administratora bierzemy z guarda 'ldap' (fallback: profil w sesji).
*/
class AuditLogger
{
/**
* @param array{
* target_type?: ?string,
* target?: ?string,
* target_dn?: ?string,
* reason?: ?string,
* changes?: ?array<string, mixed>,
* context?: ?array<string, mixed>
* } $opts
*/
public function log(string $action, array $opts = []): void
{
try {
[$actor, $actorName] = $this->resolveActor();
AuditLog::create([
'actor' => $actor,
'actor_name' => $actorName,
'action' => $action,
'target_type' => $opts['target_type'] ?? null,
'target' => $opts['target'] ?? null,
'target_dn' => $opts['target_dn'] ?? null,
'reason' => ($opts['reason'] ?? null) ?: null,
'changes' => ! empty($opts['changes']) ? $opts['changes'] : null,
'context' => ! empty($opts['context']) ? $opts['context'] : null,
'ip' => request()?->ip(),
'created_at' => now(),
]);
} catch (\Throwable $e) {
report($e);
}
}
/**
* @return array{0: string, 1: string} [login, wyświetlana nazwa]
*/
protected function resolveActor(): array
{
$user = Auth::guard('ldap')->user();
if ($user) {
$login = (string) ($user->getFirstAttribute('samaccountname') ?? 'unknown');
$name = (string) ($user->getFirstAttribute('cn') ?? $login);
return [$login, $name];
}
$profile = session('ad_admin');
if (is_array($profile) && ! empty($profile['login'])) {
return [(string) $profile['login'], (string) ($profile['display_name'] ?? $profile['login'])];
}
return ['system', 'system'];
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace App\Services;
use Carbon\Carbon;
use LdapRecord\Models\ActiveDirectory\User;
/**
* Zestaw pomocniczych metod do odczytu "surowych" atrybutów Active Directory,
* które nie są prostymi łańcuchami znaków (bitowe flagi, znaczniki czasu
* w formacie Windows FileTime itd.).
*/
class LdapAttributeHelper
{
// Wybrane flagi bitowe userAccountControl (RFC/MS-ADTS).
public const UAC_ACCOUNTDISABLE = 0x0002;
public const UAC_DONT_EXPIRE_PASSWORD = 0x10000;
public const UAC_LOCKOUT = 0x0010;
public const UAC_SERVER_TRUST_ACCOUNT = 0x1000; // konto komputera / kontrolera domeny
public static function userAccountControl(User $user): int
{
return (int) ($user->getFirstAttribute('userAccountControl') ?? 0);
}
public static function isDisabled(User $user): bool
{
return (bool) (self::userAccountControl($user) & self::UAC_ACCOUNTDISABLE);
}
public static function passwordNeverExpires(User $user): bool
{
return (bool) (self::userAccountControl($user) & self::UAC_DONT_EXPIRE_PASSWORD);
}
/**
* Konto jest zablokowane (lockout), jeśli lockoutTime jest różne od zera.
*/
public static function isLocked(User $user): bool
{
$lockoutTime = $user->getFirstAttribute('lockouttime');
return $lockoutTime !== null && (int) $lockoutTime !== 0;
}
/**
* Konwersja znacznika czasu Windows FileTime (100-nanosekundowe
* interwały od 1601-01-01) na obiekt Carbon. Wartości 0 lub
* 9223372036854775807 (nigdy) zwracają null.
*/
public static function windowsTimestampToCarbon(mixed $value): ?Carbon
{
// LdapRecord dla modeli AD sam rzutuje pola typu pwdLastSet na obiekty
// daty - wtedy nie ma czego konwertować.
if ($value instanceof \DateTimeInterface) {
return Carbon::instance($value);
}
if (is_array($value)) {
$value = $value[0] ?? null;
}
$value = (string) $value;
if ($value === '' || $value === '0' || $value === '9223372036854775807') {
return null;
}
// Różnica w sekundach między 1601-01-01 a 1970-01-01 (epoka Unix).
$unixTimestamp = intdiv((int) $value, 10_000_000) - 11_644_473_600;
if ($unixTimestamp < 0) {
return null;
}
return Carbon::createFromTimestamp($unixTimestamp);
}
public static function passwordLastSet(User $user): ?Carbon
{
return self::windowsTimestampToCarbon($user->getFirstAttribute('pwdlastset'));
}
/**
* Data wygaśnięcia KONTA (accountExpires). Wartości 0 i 0x7FFFFFFFFFFFFFFF
* oznaczają "nigdy". Czytamy surowy atrybut, bo rzutowanie LdapRecord
* potrafi zamienić "nigdy" na datę z roku 1601 / 9999.
*/
public static function accountExpiresAt(User $user): ?Carbon
{
$raw = $user->getAttributes()['accountexpires'][0] ?? null;
if ($raw === null || $raw === '' || $raw === '0' || $raw === '9223372036854775807') {
return null;
}
$carbon = self::windowsTimestampToCarbon($raw);
return ($carbon && $carbon->year > 1601 && $carbon->year < 9000) ? $carbon : null;
}
public const ACCOUNT_EXPIRES_NEVER = '9223372036854775807';
/**
* Zamienia datę na wartość accountExpires w formacie Windows FileTime
* (18-19 cyfr - LdapRecord zapisze ją bez modyfikacji).
*/
public static function dateToWindowsTimestamp(\DateTimeInterface $date): string
{
return (string) (($date->getTimestamp() + 11_644_473_600) * 10_000_000);
}
/**
* Wylicza planowaną datę wygaśnięcia hasła na podstawie polityki
* maksymalnego wieku hasła (w dniach), pobranej z domeny.
*/
public static function passwordExpiresAt(User $user, ?int $maxPasswordAgeDays): ?Carbon
{
if (self::passwordNeverExpires($user) || ! $maxPasswordAgeDays) {
return null;
}
$lastSet = self::passwordLastSet($user);
return $lastSet?->copy()->addDays($maxPasswordAgeDays);
}
/**
* Zwraca krótki, tekstowy status konta do wyświetlenia w tabeli.
*
* @return array{label: string, css: string}
*/
public static function statusBadge(User $user): array
{
if (self::isLocked($user)) {
return ['label' => __('common.account_status.locked'), 'css' => 'badge-status-locked'];
}
if (self::isDisabled($user)) {
return ['label' => __('common.account_status.disabled'), 'css' => 'badge-status-disabled'];
}
return ['label' => __('common.account_status.active'), 'css' => 'badge-status-active'];
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use LdapRecord\Models\ActiveDirectory\Computer;
use LdapRecord\Models\ActiveDirectory\Group;
use LdapRecord\Models\ActiveDirectory\OrganizationalUnit;
use LdapRecord\Models\ActiveDirectory\User;
/**
* Buduje drzewo OU ograniczone do zdefiniowanych rootów domeny,
* np. LDAP_DEFAULT_USER_OU oraz LDAP_DEFAULT_COMPUTER_OU.
*
* Zbudowane drzewo (wraz z licznikami obiektów) jest kosztowne - dla każdego
* OU leci rekursywne zapytanie zliczające - więc trzymamy je w cache pliku
* na TTL sekund. Przycisk "odśwież" w sidebarze woła OuTreeService::flush().
*/
class OuTreeService
{
/** Scope'y, dla których budujemy drzewo. */
public const SCOPES = ['users', 'computers', 'groups', 'gpo'];
protected const CACHE_TTL = 300;
protected array $tree = [];
/**
* Czyści cache drzewa OU dla wszystkich modułów.
*/
public static function flush(): void
{
foreach (self::SCOPES as $scope) {
Cache::store('file')->forget("ou_tree:{$scope}");
}
}
/**
* DN najwyższego węzła (rootu) drzewa dla danego scope - używane do
* domyślnego zaznaczenia OU po wejściu na stronę.
*/
public function rootDn(string $scope = 'users'): ?string
{
$tree = $this->tree($scope);
return $tree[0]['dn'] ?? null;
}
/**
* Zwraca drzewo OU dla konkretnego modułu (users, computers, groups, gpo).
*/
public function tree(string $scope = 'users'): array
{
$scopeKey = strtolower($scope);
if (isset($this->tree[$scopeKey])) {
return $this->tree[$scopeKey];
}
// json_encode/decode spłaszcza referencje wewnątrz drzewa (children są
// dopinane przez &$node) - bez tego cache serializowałby dziwne grafy.
$build = fn () => json_decode(json_encode($this->build($scopeKey)), true);
$tree = (self::CACHE_TTL > 0 && ! app()->runningUnitTests())
? Cache::store('file')->remember("ou_tree:{$scopeKey}", self::CACHE_TTL, $build)
: $build();
return $this->tree[$scopeKey] = $tree;
}
/**
* Właściwe zbudowanie drzewa z LDAP (bez cache).
*/
protected function build(string $scopeKey): array
{
$allowedRoots = $this->allowedRootsForScope($scopeKey);
$ous = OrganizationalUnit::query()
->select(['ou', 'distinguishedname'])
->get();
$byDn = [];
foreach ($ous as $ou) {
$dn = trim((string) $ou->getDn());
if (! $this->isWithinAllowedRoots($dn, $allowedRoots)) {
continue;
}
$normalizedDn = strtolower($dn);
$byDn[$normalizedDn] = [
'dn' => $dn,
'name' => $ou->getFirstAttribute('ou') ?? $dn,
'children' => [],
'count' => 0,
];
}
$roots = [];
foreach ($byDn as $normalizedDn => &$node) {
$parentDn = $this->parentDn($normalizedDn);
while ($parentDn && ! isset($byDn[$parentDn])) {
$parentDn = $this->parentDn($parentDn);
}
if ($parentDn && isset($byDn[$parentDn])) {
$byDn[$parentDn]['children'][] = &$node;
} else {
$roots[] = &$node;
}
}
unset($node);
foreach ($roots as &$root) {
$this->attachCounts($root, $scopeKey);
}
unset($root);
return $roots;
}
protected function attachCounts(array &$node, string $scope): int
{
foreach ($node['children'] as &$child) {
$this->attachCounts($child, $scope);
}
unset($child);
// countObjectsInOu() liczy już rekursywnie (całe poddrzewo danego OU),
// więc NIE wolno dodawać sum z dzieci - to powodowało wielokrotne
// zliczanie tych samych obiektów (np. root 313 zamiast 121).
$node['count'] = $this->countObjectsInOu($node['dn'], $scope);
return $node['count'];
}
protected function countObjectsInOu(string $ouDn, string $scope): int
{
$query = match ($scope) {
'users' => User::query()->in($ouDn)->recursive(),
'computers' => Computer::query()->in($ouDn)->recursive(),
'groups' => Group::query()->in($ouDn)->recursive(),
default => null,
};
if (! $query) {
return 0;
}
return (int) $query->select(['distinguishedname'])->get()->count();
}
protected function allowedRootsForScope(string $scope): array
{
$userOu = trim((string) config('ldap.defaults.user_ou', ''));
$computerOu = trim((string) config('ldap.defaults.computer_ou', ''));
$groupsOu = trim((string) config('ldap.defaults.groups_ou', ''));
$baseDn = trim((string) config('ldap.connections.default.base_dn', ''));
return match ($scope) {
'users' => array_values(array_filter([$userOu], fn ($value) => $value !== '')),
'computers' => array_values(array_filter([$computerOu], fn ($value) => $value !== '')),
'groups' => array_values(array_filter([$groupsOu ?: $userOu], fn ($value) => $value !== '')),
'gpo' => array_values(array_filter([$baseDn], fn ($value) => $value !== '')),
default => array_values(array_filter([$userOu, $computerOu, $groupsOu], fn ($value) => $value !== '')),
};
}
protected function isWithinAllowedRoots(string $dn, array $allowedRoots): bool
{
$normalizedDn = strtolower(trim($dn));
foreach ($allowedRoots as $root) {
$normalizedRoot = strtolower(trim((string) $root));
if ($normalizedRoot === '') {
continue;
}
if ($normalizedDn === $normalizedRoot || str_ends_with($normalizedDn, ','.$normalizedRoot)) {
return true;
}
}
return false;
}
protected function parentDn(string $dn): ?string
{
$parts = explode(',', $dn);
array_shift($parts);
$parent = implode(',', $parts);
return $parent === '' ? null : strtolower($parent);
}
}
+582
View File
@@ -0,0 +1,582 @@
<?php
namespace App\Services;
use App\Models\PanelAttribute;
use App\Models\PanelSetting;
use Illuminate\Support\Facades\DB;
/**
* Ustawienia samego panelu (nie danych z AD), trzymane w bazie:
* - ogólne (domyślne zaznaczenia resetu hasła, polityka i generator haseł,
* klucz szyfrujący) -> tabela `panel_settings`, jeden wiersz 'panel'
* (klucz -> wartość JSON),
* - definicje atrybutów AD (kolumny listy + pola profilu) -> tabela
* `panel_attributes`, po jednym wierszu na atrybut, kolejność wg `position`.
*/
class PanelSettingsService
{
/**
* Kategorie (zakładki) w oknie "Podgląd / Edycja" użytkownika, do których
* przypisywane są poszczególne atrybuty. Klucz = identyfikator zapisywany
* w konfiguracji, wartość = etykieta wyświetlana w interfejsie.
*/
public const ATTRIBUTE_CATEGORIES = [
'general' => 'Ogólne',
'org' => 'Organizacja',
'address' => 'Adres i lokalizacja',
'hr' => 'Kadry i chmura',
'account' => 'Konto AD',
];
/**
* Pozycje "na stałe": sAMAccountName (identyfikuje wiersze) i DN (ścieżka
* obiektu). Nie można ich usunąć ani zmienić ich klucza / atrybutu AD.
* Etykieta, kategoria i widoczność jako kolumna są konfigurowalne, ale typ
* jest wymuszony ("systemowy") - takich pozycji panel nie edytuje.
*
* @var list<string>
*/
public const SYSTEM_ATTRIBUTE_KEYS = ['login', 'distinguishedName'];
/**
* Kolumny wyliczane przez panel (nie są to atrybuty AD): status konta
* oraz wygasanie hasła. W zakładce "Atrybuty" można nimi zarządzać jak
* kolumnami (kolejność, widoczność), ale nie da się ich edytować, usunąć
* ani zmienić ich klucza / typu.
*
* @var list<string>
*/
public const COMPUTED_ATTRIBUTE_KEYS = ['status', 'password_expiry'];
/**
* Pozycje "na stałe", ale będące realnymi atrybutami AD: klucza ani nazwy
* atrybutu AD nie da się zmienić i nie można ich usunąć, natomiast kategorię
* ustawia się normalnie. Wartość mapy to kanoniczna nazwa atrybutu AD
* (zapis / odczyt ma dedykowaną obsługę w kontrolerze - accountExpires to
* Windows FileTime, nie zwykły string). Typ takiej pozycji jest wymuszony.
*
* @var array<string, string>
*/
public const FIXED_ATTRIBUTE_KEYS = ['account_expiry' => 'accountExpires'];
/**
* Wymuszony typ pozycji "na stałe" (patrz FIXED_ATTRIBUTE_KEYS).
*
* @var array<string, string>
*/
public const FIXED_ATTRIBUTE_TYPES = ['account_expiry' => 'date'];
/**
* Typ atrybutu w oknie "Podgląd / Edycja". Zastępuje dawne osobne flagi
* "edytowalny" i "szyfrowany" - jeden wybór z listy w zakładce "Atrybuty".
*
* Edytowalne: text | number | date | encrypted
* Nieedytowalne: system | account_status | password_expiry
*
* @var array<string, string>
*/
public const ATTRIBUTE_TYPE_LABELS = [
'text' => 'Tekst',
'number' => 'Liczba',
'date' => 'Data',
'encrypted' => 'Tekst szyfrowany',
'system' => 'Systemowy',
'account_status' => 'Status konta',
'password_expiry' => 'Wygaśnięcie hasła',
];
/** Typy, które użytkownik może wybrać dla zwykłego atrybutu AD. */
public const EDITABLE_ATTRIBUTE_TYPES = ['text', 'number', 'date', 'encrypted'];
/** Wszystkie dopuszczalne wartości typu. */
public const ATTRIBUTE_TYPES = [
'text', 'number', 'date', 'encrypted', 'system', 'account_status', 'password_expiry',
];
/** Klucz wiersza w tabeli `panel_settings`, pod którym zapisujemy całą strukturę. */
protected const STORE_KEY = 'panel';
public function __construct()
{
//
}
/**
* Domyślna, wbudowana konfiguracja panelu.
*
* @return array<string, mixed>
*/
public function defaults(): array
{
return [
'reset_password' => [
'must_change_password' => true,
'unlock_account' => true,
],
'password_policy' => [
// Maksymalny wiek hasła w dniach (domyślne AD = 42).
'max_age_days' => 42,
// Generator haseł w panelu (nie zmienia polityki w AD).
'generator_length' => 16,
'generator_symbols' => true,
],
'encryption' => [
// Klucz (hasło) do odszyfrowania / zaszyfrowania atrybutów
// oznaczonych jako "szyfrowany". Puste = szyfrowanie wyłączone.
'key' => '',
],
'audit' => [
// Wymuś podanie powodu przy akcjach niszczących (wyłącz / usuń /
// przenieś / offboarding). Wpis w dzienniku powstaje zawsze.
'require_reason' => false,
],
];
}
/**
* Czy panel wymaga podania powodu przy akcjach niszczących.
*/
public function requireReason(): bool
{
return (bool) ($this->all()['audit']['require_reason'] ?? false);
}
/**
* Maksymalny wiek hasła w dniach (0 = brak polityki wygasania).
*/
public function passwordMaxAgeDays(): int
{
$value = (int) ($this->all()['password_policy']['max_age_days'] ?? 42);
return max(0, $value);
}
/**
* Ustawienia generatora haseł (tylko w panelu - nie dotyka polityki AD).
*
* @return array{length:int,symbols:bool}
*/
public function passwordGenerator(): array
{
$policy = $this->all()['password_policy'] ?? [];
return [
'length' => max(8, min(128, (int) ($policy['generator_length'] ?? 16))),
'symbols' => (bool) ($policy['generator_symbols'] ?? true),
];
}
/**
* Klucz szyfrujący atrybuty (pusty = szyfrowanie wyłączone).
*/
public function encryptionKey(): string
{
return trim((string) ($this->all()['encryption']['key'] ?? ''));
}
/**
* Wbudowana lista atrybutów widocznych w panelu. Stanowi punkt wyjścia
* dla konfiguracji użytkownika (zakładka "Atrybuty").
*
* @return list<array<string, mixed>>
*/
protected function defaultAttributes(): array
{
return [
// --- Tożsamość (domyślnie widoczne kolumny listy) --------------
['key' => 'login', 'label' => 'sAMAccountName', 'ldap' => 'sAMAccountName', 'category' => 'account', 'type' => 'system', 'column' => true],
['key' => 'displayName', 'label' => 'Imię i Nazwisko', 'ldap' => 'displayName', 'category' => 'general', 'type' => 'text', 'column' => true],
['key' => 'email', 'label' => 'Adres e-mail', 'ldap' => 'mail', 'category' => 'general', 'type' => 'text', 'column' => true],
// --- Ogólne -----------------------------------------------------
['key' => 'firstName', 'label' => 'Imię', 'ldap' => 'givenName', 'category' => 'general', 'type' => 'text', 'column' => false],
['key' => 'lastName', 'label' => 'Nazwisko', 'ldap' => 'sn', 'category' => 'general', 'type' => 'text', 'column' => false],
['key' => 'telephoneNumber', 'label' => 'Telefon stacjonarny', 'ldap' => 'telephoneNumber', 'category' => 'general', 'type' => 'text', 'column' => false],
['key' => 'mobile', 'label' => 'Telefon komórkowy', 'ldap' => 'mobile', 'category' => 'general', 'type' => 'text', 'column' => false],
// --- Organizacja ---------------------------------------------------
['key' => 'title', 'label' => 'Stanowisko', 'ldap' => 'title', 'category' => 'org', 'type' => 'text', 'column' => false],
['key' => 'department', 'label' => 'Dział / Departament', 'ldap' => 'department', 'category' => 'org', 'type' => 'text', 'column' => false],
['key' => 'company', 'label' => 'Firma / Spółka', 'ldap' => 'company', 'category' => 'org', 'type' => 'text', 'column' => false],
['key' => 'manager', 'label' => 'Kierownik', 'ldap' => 'manager', 'category' => 'org', 'type' => 'text', 'column' => false],
// --- Adres i lokalizacja ----------------------------------------
['key' => 'co', 'label' => 'Kraj (co)', 'ldap' => 'co', 'category' => 'address', 'type' => 'text', 'column' => false],
['key' => 'st', 'label' => 'Województwo (st)', 'ldap' => 'st', 'category' => 'address', 'type' => 'text', 'column' => false],
['key' => 'l', 'label' => 'Miasto (l)', 'ldap' => 'l', 'category' => 'address', 'type' => 'text', 'column' => false],
['key' => 'postalCode', 'label' => 'Kod pocztowy', 'ldap' => 'postalCode', 'category' => 'address', 'type' => 'text', 'column' => false],
['key' => 'streetAddress', 'label' => 'Ulica i numer domu', 'ldap' => 'streetAddress', 'category' => 'address', 'type' => 'text', 'column' => false],
['key' => 'roomNumber', 'label' => 'Numer pokoju / biurka', 'ldap' => 'roomNumber', 'category' => 'address', 'type' => 'text', 'column' => false],
['key' => 'physicalDeliveryOfficeName', 'label' => 'Główna lokalizacja', 'ldap' => 'physicalDeliveryOfficeName', 'category' => 'address', 'type' => 'text', 'column' => false],
// --- Kadry i chmura -------------------------------------------------
['key' => 'employeeID', 'label' => 'ID pracownika (Snipe-IT)', 'ldap' => 'employeeID', 'category' => 'hr', 'type' => 'text', 'column' => false],
['key' => 'employeeNumber', 'label' => 'Kod pracownika (PIN)', 'ldap' => 'employeeNumber', 'category' => 'hr', 'type' => 'text', 'column' => false],
['key' => 'microsoft', 'label' => 'Microsoft Cloud', 'ldap' => 'msDS-cloudExtensionAttribute1', 'category' => 'hr', 'type' => 'encrypted', 'column' => false],
['key' => 'google', 'label' => 'Google Cloud', 'ldap' => 'msDS-cloudExtensionAttribute2', 'category' => 'hr', 'type' => 'encrypted', 'column' => false],
// --- Konto AD ----------------------------------------------------
['key' => 'distinguishedName', 'label' => 'Ścieżka (DN)', 'ldap' => 'distinguishedName', 'category' => 'account', 'type' => 'system', 'column' => false],
['key' => 'account_expiry', 'label' => 'Wygasanie konta', 'ldap' => 'accountExpires', 'category' => 'account', 'type' => 'date', 'column' => false],
// --- Kolumny wyliczane (nie są atrybutami AD) ---------------------
['key' => 'status', 'label' => 'Status konta', 'ldap' => 'status', 'category' => 'account', 'type' => 'account_status', 'column' => true],
['key' => 'password_expiry', 'label' => 'Wygasanie hasła', 'ldap' => 'password_expiry', 'category' => 'account', 'type' => 'password_expiry', 'column' => true],
];
}
/**
* Ustawienia ogólne (bez definicji atrybutów - te ma `attributes()`).
*
* @return array<string, mixed>
*/
public function all(): array
{
return array_replace_recursive($this->defaults(), $this->readStored());
}
/**
* Surowy wiersz 'panel' z tabeli `panel_settings` (bez scalania z
* domyślnymi). Gdy tabeli jeszcze nie ma (np. przed migracją) - pusto.
*
* @return array<string, mixed>
*/
protected function readStored(): array
{
try {
$value = PanelSetting::query()->find(self::STORE_KEY)?->value;
return is_array($value) ? $value : [];
} catch (\Throwable) {
return [];
}
}
public function save(array $settings): void
{
PanelSetting::query()->updateOrCreate(
['key' => self::STORE_KEY],
['value' => $settings],
);
}
// =====================================================================
// Atrybuty / kolumny
// =====================================================================
/**
* Klucze, których nie wolno usunąć (systemowe + wyliczane).
*
* @return list<string>
*/
protected function lockedKeys(): array
{
return array_merge(
self::SYSTEM_ATTRIBUTE_KEYS,
self::COMPUTED_ATTRIBUTE_KEYS,
array_keys(self::FIXED_ATTRIBUTE_KEYS),
);
}
/**
* Znormalizowana lista wszystkich zdefiniowanych atrybutów, z gwarancją
* obecności pozycji systemowych i wyliczanych oraz bez duplikatów kluczy.
*
* @return list<array{key:string,label:string,ldap:string,category:string,type:string,input:string,editable:bool,column:bool,system:bool,computed:bool,fixed:bool,encrypted:bool}>
*/
public function attributes(): array
{
// Zapisana konfiguracja z tabeli `panel_attributes`; brak wierszy =
// użytkownik jeszcze nic nie zmieniał -> wbudowana lista domyślna.
$list = $this->storedAttributeRows() ?: $this->defaultAttributes();
$normalized = [];
$seen = [];
foreach ($list as $item) {
if (! is_array($item)) {
continue;
}
$attr = $this->normalizeAttribute($item);
if ($attr['key'] === '') {
continue;
}
$lower = strtolower($attr['key']);
if (isset($seen[$lower])) {
continue;
}
$seen[$lower] = true;
$normalized[] = $attr;
}
// Pozycje systemowe i wyliczane muszą istnieć zawsze - odtwarzamy brakujące.
$lockedKeys = $this->lockedKeys();
foreach ($this->defaultAttributes() as $default) {
if (! in_array($default['key'], $lockedKeys, true)) {
continue;
}
if (isset($seen[strtolower($default['key'])])) {
continue;
}
$normalized[] = $this->normalizeAttribute($default);
$seen[strtolower($default['key'])] = true;
}
return $normalized;
}
/**
* Wiersze z tabeli `panel_attributes` w kolejności `position`, sprowadzone
* do kształtu przyjmowanego przez normalizeAttribute(). Brak tabeli
* (np. przed migracją) traktujemy jak brak konfiguracji.
*
* @return list<array<string, mixed>>
*/
protected function storedAttributeRows(): array
{
try {
return PanelAttribute::query()
->orderBy('position')
->orderBy('id')
->get()
->map(fn (PanelAttribute $a) => [
'key' => $a->key,
'label' => $a->label,
'ldap' => $a->ldap,
'category' => $a->category,
'type' => $a->type,
'regex' => $a->regex,
'required' => $a->required,
'help' => $a->help,
'column' => $a->is_column,
])
->all();
} catch (\Throwable) {
return [];
}
}
/**
* Zapisuje przesłaną listę atrybutów, wymuszając reguły dla pozycji
* systemowych i wyliczanych oraz odtwarzając te, które ktoś próbował usunąć.
*
* @param iterable<array<string, mixed>> $attributes
*/
public function saveAttributes(iterable $attributes): void
{
$lockedDefaults = [];
foreach ($this->defaultAttributes() as $default) {
if (in_array($default['key'], $this->lockedKeys(), true)) {
$lockedDefaults[$default['key']] = $this->normalizeAttribute($default);
}
}
$clean = [];
$seen = [];
foreach ($attributes as $item) {
if (! is_array($item)) {
continue;
}
$attr = $this->normalizeAttribute($item);
if ($attr['key'] === '') {
continue;
}
$lower = strtolower($attr['key']);
if (isset($seen[$lower])) {
continue;
}
if (isset($lockedDefaults[$attr['key']])) {
$def = $lockedDefaults[$attr['key']];
// Typ, editable/encrypted oraz ldap dla kolumn wyliczanych /
// "na stałe" wymusza już normalizeAttribute po kluczu. Zostaje
// przywrócenie nazwy atrybutu AD dla pozycji systemowych oraz
// stałej kategorii dla kolumn wyliczanych.
$attr['ldap'] = $def['ldap'];
if ($def['computed']) {
$attr['category'] = $def['category'];
}
}
$seen[$lower] = true;
$clean[] = $attr;
}
foreach ($lockedDefaults as $key => $def) {
if (isset($seen[strtolower($key)])) {
continue;
}
$clean[] = $def;
}
// Nadpisujemy całą tabelę - lista jest krótka, a to najprościej trzyma
// kolejność (position) i usunięcia w zgodzie z wynikiem powyżej.
DB::transaction(function () use ($clean) {
PanelAttribute::query()->delete();
$now = now();
$rows = [];
foreach (array_values($clean) as $i => $attr) {
$rows[] = [
'key' => $attr['key'],
'label' => $attr['label'],
'ldap' => $attr['ldap'],
'category' => $attr['category'],
'type' => $attr['type'],
'regex' => $attr['regex'] !== '' ? $attr['regex'] : null,
'required' => $attr['required'],
'help' => $attr['help'],
'is_column' => $attr['column'],
'position' => $i,
'created_at' => $now,
'updated_at' => $now,
];
}
if ($rows !== []) {
PanelAttribute::query()->insert($rows);
}
});
}
/**
* Atrybuty / kolumny wyliczane oznaczone do wyświetlenia jako kolumna
* listy użytkowników - w kolejności zapisanej w konfiguracji.
*
* @return list<array<string, mixed>>
*/
public function attributeColumns(): array
{
return array_values(array_filter($this->attributes(), static fn ($a) => $a['column']));
}
/**
* Atrybuty pogrupowane wg kategorii (zakładek podglądu użytkownika).
* Pozycje wyliczane są pomijane - nie są polami profilu.
*
* @return array<string, list<array<string, mixed>>>
*/
public function attributesByCategory(): array
{
$grouped = [];
foreach (array_keys(self::ATTRIBUTE_CATEGORIES) as $catId) {
$grouped[$catId] = [];
}
foreach ($this->attributes() as $attr) {
if ($attr['computed']) {
continue;
}
$grouped[$attr['category']][] = $attr;
}
return $grouped;
}
/**
* Wyłącznie atrybuty edytowalne (bez wyliczanych), pogrupowane wg kategorii.
* Zwraca tylko te kategorie, które mają co najmniej jedno pole - to jest
* dokładnie zestaw pól pokazywanych w oknie "Podgląd / Edycja".
*
* @return array<string, list<array<string, mixed>>>
*/
public function editableAttributesByCategory(): array
{
$grouped = [];
foreach ($this->attributes() as $attr) {
if ($attr['computed'] || ! $attr['editable']) {
continue;
}
$grouped[$attr['category']][] = $attr;
}
$ordered = [];
foreach (array_keys(self::ATTRIBUTE_CATEGORIES) as $catId) {
if (! empty($grouped[$catId])) {
$ordered[$catId] = $grouped[$catId];
}
}
return $ordered;
}
/**
* @return array<string, string>
*/
public function categories(): array
{
return self::ATTRIBUTE_CATEGORIES;
}
/**
* @param array<string, mixed> $a
* @return array{key:string,label:string,ldap:string,category:string,type:string,input:string,editable:bool,column:bool,system:bool,computed:bool,fixed:bool,encrypted:bool}
*/
protected function normalizeAttribute(array $a): array
{
$key = preg_replace('/[^A-Za-z0-9_-]/', '', (string) ($a['key'] ?? ''));
$ldap = trim((string) ($a['ldap'] ?? ''));
$category = (string) ($a['category'] ?? 'general');
$computed = in_array($key, self::COMPUTED_ATTRIBUTE_KEYS, true);
$system = in_array($key, self::SYSTEM_ATTRIBUTE_KEYS, true);
$fixedLdap = self::FIXED_ATTRIBUTE_KEYS[$key] ?? null;
// Typ pozycji wbudowanych jest wymuszony; dla zwykłych atrybutów
// pochodzi z wyboru użytkownika (lista w zakładce "Atrybuty") i musi
// być jednym z typów edytowalnych.
if ($system) {
$type = 'system';
} elseif ($computed) {
$type = $key === 'status' ? 'account_status' : 'password_expiry';
} elseif ($fixedLdap !== null) {
$type = self::FIXED_ATTRIBUTE_TYPES[$key] ?? 'text';
} else {
$type = (string) ($a['type'] ?? 'text');
if (! in_array($type, self::EDITABLE_ATTRIBUTE_TYPES, true)) {
$type = 'text';
}
}
$editable = in_array($type, self::EDITABLE_ATTRIBUTE_TYPES, true);
return [
'key' => $key,
'label' => trim((string) ($a['label'] ?? '')) ?: $key,
// Klucz i atrybut AD pozycji "na stałe" są niezmienne; kolumny
// wyliczane nie są atrybutami AD.
'ldap' => $fixedLdap ?? ($computed ? $key : ($ldap !== '' ? $ldap : $key)),
'category' => array_key_exists($category, self::ATTRIBUTE_CATEGORIES) ? $category : 'general',
'type' => $type,
// Rodzaj pola formularza w oknie "Podgląd / Edycja".
'input' => in_array($type, ['number', 'date'], true) ? $type : 'text',
'editable' => $editable,
// Reguły walidacji pola profilu - tylko dla atrybutów edytowalnych.
'regex' => $editable ? trim((string) ($a['regex'] ?? '')) : '',
'required' => $editable ? filter_var($a['required'] ?? false, FILTER_VALIDATE_BOOL) : false,
'help' => $editable ? trim((string) ($a['help'] ?? '')) : '',
'column' => filter_var($a['column'] ?? false, FILTER_VALIDATE_BOOL),
'system' => $system,
'computed' => $computed,
'fixed' => $fixedLdap !== null,
'encrypted' => $type === 'encrypted',
];
}
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);
+38
View File
@@ -0,0 +1,38 @@
<?php
use App\Http\Middleware\SetLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
/*
* PHP 8.5 zgłasza E_DEPRECATED dla stałej PDO::MYSQL_ATTR_SSL_CA używanej
* w domyślnej konfiguracji frameworka (Laravel 11.x). Przy display_errors=1
* ten komunikat trafiał na początek odpowiedzi HTTP - psując m.in. odpowiedzi
* JSON (modale "Podgląd / Edycja" użytkownika, "Członkowie" grupy).
* Wyciszamy wyłącznie deprecacje; pozostałe błędy raportowane są normalnie.
*/
error_reporting(error_reporting() & ~E_DEPRECATED & ~E_USER_DEPRECATED);
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// Niezalogowany -> ekran logowania; zalogowany wchodzący na /logowanie -> pulpit.
$middleware->redirectGuestsTo(fn () => route('login'));
$middleware->redirectUsersTo('/dashboard');
// Język interfejsu (sesja / cookie) - po StartSession w grupie 'web'.
$middleware->web(append: [
SetLocale::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})->create();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];
+67
View File
@@ -0,0 +1,67 @@
{
"name": "adconsole/adconsole",
"type": "project",
"description": "ADconsole - a Laravel + LdapRecord web panel to manage an Active Directory domain (users, computers, groups, read-only GPO).",
"keywords": ["laravel", "active-directory", "ldap", "ldaprecord", "admin-panel", "self-hosted"],
"license": "MIT",
"require": {
"php": "^8.2",
"directorytree/ldaprecord-laravel": "^4.0",
"laravel/framework": "^11.0",
"laravel/tinker": "^2.9"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/boost": "^2.6",
"laravel/pint": "^1.13",
"laravel/sail": "^1.26",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.1",
"phpunit/phpunit": "^10.5",
"spatie/laravel-ignition": "^2.4"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-create-project-cmd": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
"@php artisan key:generate --ansi"
],
"test": [
"@php artisan config:clear --ansi",
"@php artisan test"
],
"lint": "pint --test",
"format": "pint"
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
},
"policy": {
"advisories": {
"block": false
}
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
Generated
+8973
View File
File diff suppressed because it is too large. Load diff
+126
View File
@@ -0,0 +1,126 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Application Name
|--------------------------------------------------------------------------
|
| This value is the name of your application, which will be used when the
| framework needs to place the application's name in a notification or
| other UI elements where an application name needs to be displayed.
|
*/
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Application Environment
|--------------------------------------------------------------------------
|
| This value determines the "environment" your application is currently
| running in. This may determine how you prefer to configure various
| services the application utilizes. Set this in your ".env" file.
|
*/
'env' => env('APP_ENV', 'production'),
/*
|--------------------------------------------------------------------------
| Application Debug Mode
|--------------------------------------------------------------------------
|
| When your application is in debug mode, detailed error messages with
| stack traces will be shown on every error that occurs within your
| application. If disabled, a simple generic error page is shown.
|
*/
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Application URL
|--------------------------------------------------------------------------
|
| This URL is used by the console to properly generate URLs when using
| the Artisan command line tool. You should set this to the root of
| the application so that it's available within Artisan commands.
|
*/
'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. The timezone
| is set to "UTC" by default as it is suitable for most use cases.
|
*/
'timezone' => 'UTC',
/*
|--------------------------------------------------------------------------
| Application Locale Configuration
|--------------------------------------------------------------------------
|
| The application locale determines the default locale that will be used
| by Laravel's translation / localization methods. This option can be
| set to any locale for which you plan to have translation strings.
|
*/
'locale' => env('APP_LOCALE', 'pl'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| This key is utilized by Laravel's encryption services and should be set
| to a random, 32 character string to ensure that all encrypted values
| are secure. You should do this prior to deploying the application.
|
*/
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
),
],
/*
|--------------------------------------------------------------------------
| Maintenance Mode Driver
|--------------------------------------------------------------------------
|
| These configuration options determine the driver used to determine and
| manage Laravel's "maintenance mode" status. The "cache" driver will
| allow maintenance mode to be controlled across multiple machines.
|
| Supported drivers: "file", "cache", "array"
|
*/
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];
+130
View File
@@ -0,0 +1,130 @@
<?php
use App\Ldap\Rules\MemberOfLoginGroup;
use App\Models\User;
return [
/*
|--------------------------------------------------------------------------
| Authentication Defaults
|--------------------------------------------------------------------------
|
| This option defines the default authentication "guard" and password
| reset "broker" for your application. You may change these values
| as required, but they're a perfect start for most applications.
|
*/
'defaults' => [
'guard' => env('AUTH_GUARD', 'ldap'),
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
],
/*
|--------------------------------------------------------------------------
| Authentication Guards
|--------------------------------------------------------------------------
|
| Next, you may define every authentication guard for your application.
| Of course, a great default configuration has been defined for you
| which utilizes session storage plus the Eloquent user provider.
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| Supported: "session"
|
*/
'guards' => [
'web' => [
'driver' => 'session',
'provider' => 'users',
],
// Logowanie do panelu kontem Active Directory (LdapRecord).
'ldap' => [
'driver' => 'session',
'provider' => 'ldap-users',
],
],
/*
|--------------------------------------------------------------------------
| User Providers
|--------------------------------------------------------------------------
|
| All authentication guards have a user provider, which defines how the
| users are actually retrieved out of your database or other storage
| system used by the application. Typically, Eloquent is utilized.
|
| If you have multiple user tables or models you may configure multiple
| providers to represent the model / table. These providers may then
| be assigned to any extra authentication guards you have defined.
|
| Supported: "database", "eloquent"
|
*/
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', User::class),
],
// LdapRecord "plain bind" - brak lokalnej tabeli users. Uwierzytelnia
// bindowaniem do kontrolera domeny, a autoryzację (dostęp do panelu)
// wymusza reguła MemberOfLoginGroup na bazie config('ldap.auth.login_group').
'ldap-users' => [
'driver' => 'ldap',
'model' => App\Ldap\User::class,
'rules' => [
MemberOfLoginGroup::class,
],
],
],
/*
|--------------------------------------------------------------------------
| Resetting Passwords
|--------------------------------------------------------------------------
|
| These configuration options specify the behavior of Laravel's password
| reset functionality, including the table utilized for token storage
| and the user provider that is invoked to actually retrieve users.
|
| The expiry time is the number of minutes that each reset token will be
| considered valid. This security feature keeps tokens short-lived so
| they have less time to be guessed. You may change this as needed.
|
| The throttle setting is the number of seconds a user must wait before
| generating more password reset tokens. This prevents the user from
| quickly generating a very large amount of password reset tokens.
|
*/
'passwords' => [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'throttle' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Password Confirmation Timeout
|--------------------------------------------------------------------------
|
| Here you may define the number of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours.
|
*/
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
];
+136
View File
@@ -0,0 +1,136 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| This option controls the default cache store that will be used by the
| framework. This connection is utilized if another isn't explicitly
| specified when running a cache operation inside the application.
|
*/
'default' => env('CACHE_STORE', 'database'),
/*
|--------------------------------------------------------------------------
| Cache Stores
|--------------------------------------------------------------------------
|
| Here you may define all of the cache "stores" for your application as
| well as their drivers. You may even define multiple stores for the
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "storage", "octane",
| "session", "failover", "null"
|
*/
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'storage' => [
'driver' => 'storage',
'disk' => env('CACHE_STORAGE_DISK'),
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
],
/*
|--------------------------------------------------------------------------
| Cache Key Prefix
|--------------------------------------------------------------------------
|
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
| stores, there might be other applications using the same cache. For
| that reason, you may prefix every cache key to avoid collisions.
|
*/
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
/*
|--------------------------------------------------------------------------
| Serializable Classes
|--------------------------------------------------------------------------
|
| This value determines the classes that can be unserialized from cache
| storage. By default, no PHP classes will be unserialized from your
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
*/
'serializable_classes' => false,
];
+184
View File
@@ -0,0 +1,184 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
return [
/*
|--------------------------------------------------------------------------
| Default Database Connection Name
|--------------------------------------------------------------------------
|
| Here you may specify which of the database connections below you wish
| to use as your default connection for database operations. This is
| the connection which will be utilized unless another connection
| is explicitly specified when you execute a query / statement.
|
*/
'default' => env('DB_CONNECTION', 'sqlite'),
/*
|--------------------------------------------------------------------------
| Database Connections
|--------------------------------------------------------------------------
|
| Below are all of the database connections defined for your application.
| An example configuration is provided for each database system which
| is supported by Laravel. You're free to add / remove connections.
|
*/
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
],
/*
|--------------------------------------------------------------------------
| Migration Repository Table
|--------------------------------------------------------------------------
|
| This table keeps track of all the migrations that have already run for
| your application. Using this information, we can determine which of
| the migrations on disk haven't actually been run on the database.
|
*/
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
/*
|--------------------------------------------------------------------------
| Redis Databases
|--------------------------------------------------------------------------
|
| Redis is an open source, fast, and advanced key-value store that also
| provides a richer body of commands than a typical key-value system
| such as Memcached. You may define your connection settings here.
|
*/
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
'persistent' => env('REDIS_PERSISTENT', false),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
],
],
];
+80
View File
@@ -0,0 +1,80 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
'report' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];
+147
View File
@@ -0,0 +1,147 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default LDAP Connection Name
|--------------------------------------------------------------------------
*/
'default' => env('LDAP_CONNECTION', 'default'),
/*
|--------------------------------------------------------------------------
| Logowanie administratora do panelu
|--------------------------------------------------------------------------
|
| Do panelu logujemy się kontem Active Directory (LdapRecord "plain bind",
| bez lokalnej tabeli users). Dostęp mają wyłącznie członkowie grupy
| wskazanej w AD_LOGIN_GROUP - pełny DN grupy (zalecane) albo sama nazwa
| (CN). Uwzględniane jest członkostwo zagnieżdżone (grupa w grupie) oraz
| grupa podstawowa. Pusta wartość => nikt nie może się zalogować.
|
*/
'auth' => [
'login_group' => env('AD_LOGIN_GROUP'),
],
/*
|--------------------------------------------------------------------------
| LDAP Connections
|--------------------------------------------------------------------------
|
| Konto z LDAP_USERNAME / LDAP_PASSWORD to konto serwisowe aplikacji -
| używane wyłącznie do odpytywania i modyfikowania obiektów w AD. Panel
| NIE prowadzi lokalnej tabeli kont administratorów - logują się oni
| własnym kontem domenowym (guard 'ldap', patrz sekcja 'auth' powyżej).
|
| Szyfrowanie połączenia:
| - LDAP_SSL=true -> LDAPS na porcie 636 (zalecane produkcyjnie),
| - LDAP_TLS=true -> STARTTLS na porcie 389,
| - oba false -> połączenie nieszyfrowane (tylko lab; reset hasła
| i inne zapisy AD wtedy NIE zadziałają).
|
*/
'connections' => [
'default' => [
'hosts' => explode(',', env('LDAP_HOSTS', env('LDAP_HOST', '127.0.0.1'))),
'username' => env('LDAP_USERNAME'),
'password' => env('LDAP_PASSWORD'),
'port' => (int) env('LDAP_PORT', 636),
'base_dn' => env('LDAP_BASE_DN'),
'timeout' => (int) env('LDAP_TIMEOUT', 5),
// LdapRecord v4: `use_tls` = LDAPS (ldaps://, zwykle port 636),
// `use_starttls` = STARTTLS na zwykłym porcie 389. Włącz dokładnie jedno.
'use_tls' => filter_var(env('LDAP_SSL', true), FILTER_VALIDATE_BOOL),
'use_starttls' => filter_var(env('LDAP_TLS', false), FILTER_VALIDATE_BOOL),
'version' => 3,
'follow_referrals' => false,
'options' => [
// Dla LDAPS z certyfikatem DC niezaufanym przez serwer (self-signed
// / wewnętrzne CA) można wyłączyć weryfikację: LDAP_TLS_VERIFY=false.
LDAP_OPT_X_TLS_REQUIRE_CERT => filter_var(env('LDAP_TLS_VERIFY', false), FILTER_VALIDATE_BOOL)
? 2 // LDAP_OPT_X_TLS_DEMAND
: 0, // LDAP_OPT_X_TLS_NEVER
],
],
],
/*
|--------------------------------------------------------------------------
| Logging
|--------------------------------------------------------------------------
*/
'logging' => env('LDAP_LOGGING', true),
/*
|--------------------------------------------------------------------------
| Cache
|--------------------------------------------------------------------------
*/
'cache' => [
'enabled' => env('LDAP_CACHE', false),
'driver' => env('LDAP_CACHE_DRIVER', 'file'),
],
/*
|--------------------------------------------------------------------------
| Domyślne kontenery (OU) używane przy tworzeniu nowych obiektów
|--------------------------------------------------------------------------
*/
'defaults' => [
'user_ou' => env('LDAP_DEFAULT_USER_OU'),
'computer_ou' => env('LDAP_DEFAULT_COMPUTER_OU'),
'groups_ou' => env('LDAP_DEFAULT_GROUPS_OU'),
'domain_label' => env('AD_DOMAIN_LABEL', env('LDAP_BASE_DN')),
],
/*
|--------------------------------------------------------------------------
| Mapowanie atrybutów AD -> profil w sesji
|--------------------------------------------------------------------------
|
| Po zalogowaniu profil administratora budowany jest na podstawie tego
| mapowania (App\Ldap\User::toPanelProfile()) i trzymany w sesji pod
| kluczem 'ad_admin'. Zestaw atrybutów widocznych w module "Użytkownicy"
| konfiguruje się osobno w panelu (/settings -> zakładka "Atrybuty").
|
*/
'attributes' => [
'user' => [
'login' => 'samaccountname',
'first_name' => 'givenname',
'last_name' => 'sn',
'display_name' => 'displayname',
'mail' => 'mail',
'distinguished_name' => 'distinguishedname',
'title' => 'title',
'department' => 'department',
'company' => 'company',
'telephone' => 'telephonenumber',
'mobile' => 'mobile',
'country' => 'c',
'state' => 'st',
'city' => 'l',
'postal_code' => 'postalcode',
'street_address' => 'streetaddress',
'room_number' => 'roomnumber',
'phisical_delivery_office_name' => 'physicaldeliveryofficename',
'employee_id' => 'employeeid',
'employee_number' => 'employeenumber',
'memberof' => 'memberof',
],
'group' => [
'name' => 'cn',
],
],
];
+18
View File
@@ -0,0 +1,18 @@
<?php
/*
|--------------------------------------------------------------------------
| Obsługiwane języki panelu ADconsole
|--------------------------------------------------------------------------
|
| Jedno źródło prawdy o dostępnych językach: używane przez przełącznik
| języka (na ekranie logowania i w panelu), middleware SetLocale oraz
| LocaleController. Klucz = kod locale (katalog w lang/), wartość = nazwa
| własna języka pokazywana w UI.
|
*/
return [
'pl' => 'Polski',
'en' => 'English',
];
+140
View File
@@ -0,0 +1,140 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that is utilized to write
| messages to your logs. The value provided here should match one of
| the channels present in the list of "channels" configured below.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Deprecations Log Channel
|--------------------------------------------------------------------------
|
| This option controls the log channel that should be used to log warnings
| regarding deprecated PHP and library features. This allows you to get
| your application ready for upcoming major versions of dependencies.
|
*/
'deprecations' => [
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
],
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Laravel
| utilizes the Monolog PHP logging library, which includes a variety
| of powerful log handlers and formatters that you're free to use.
|
| Available drivers: "single", "daily", "monthly", "slack", "syslog",
| "errorlog", "monolog", "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'max_files' => env('LOG_DAILY_DAYS', 14),
'replace_placeholders' => true,
],
'monthly' => [
'driver' => 'monthly',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'max_files' => 3,
'replace_placeholders' => true,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
'level' => env('LOG_LEVEL', 'critical'),
'replace_placeholders' => true,
],
'papertrail' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
],
'processors' => [PsrLogMessageProcessor::class],
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'handler_with' => [
'stream' => 'php://stderr',
],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => env('LOG_LEVEL', 'debug'),
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];
+118
View File
@@ -0,0 +1,118 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Mailer
|--------------------------------------------------------------------------
|
| This option controls the default mailer that is used to send all email
| messages unless another mailer is explicitly specified when sending
| the message. All additional mailers can be configured within the
| "mailers" array. Examples of each type of mailer are provided.
|
*/
'default' => env('MAIL_MAILER', 'log'),
/*
|--------------------------------------------------------------------------
| Mailer Configurations
|--------------------------------------------------------------------------
|
| Here you may configure all of the mailers used by your application plus
| their respective settings. Several examples have been configured for
| you and you are free to add your own as your application requires.
|
| Laravel supports a variety of mail "transport" drivers that can be used
| when delivering an email. You may specify which one you're using for
| your mailers below. You may also add additional mailers if needed.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| "postmark", "resend", "log", "array",
| "failover", "roundrobin"
|
*/
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'scheme' => env('MAIL_SCHEME'),
'url' => env('MAIL_URL'),
'host' => env('MAIL_HOST', '127.0.0.1'),
'port' => env('MAIL_PORT', 2525),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
],
'ses' => [
'transport' => 'ses',
],
'postmark' => [
'transport' => 'postmark',
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
// 'client' => [
// 'timeout' => 5,
// ],
],
'resend' => [
'transport' => 'resend',
],
'sendmail' => [
'transport' => 'sendmail',
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
'array' => [
'transport' => 'array',
],
'failover' => [
'transport' => 'failover',
'mailers' => [
'smtp',
'log',
],
'retry_after' => 60,
],
'roundrobin' => [
'transport' => 'roundrobin',
'mailers' => [
'ses',
'postmark',
],
'retry_after' => 60,
],
],
/*
|--------------------------------------------------------------------------
| Global "From" Address
|--------------------------------------------------------------------------
|
| You may wish for all emails sent by your application to be sent from
| the same address. Here you may specify a name and address that is
| used globally for all emails that are sent by your application.
|
*/
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
],
];
+129
View File
@@ -0,0 +1,129 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Queue Connection Name
|--------------------------------------------------------------------------
|
| Laravel's queue supports a variety of backends via a single, unified
| API, giving you convenient access to each backend using identical
| syntax for each. The default queue connection is defined below.
|
*/
'default' => env('QUEUE_CONNECTION', 'database'),
/*
|--------------------------------------------------------------------------
| Queue Connections
|--------------------------------------------------------------------------
|
| Here you may configure the connection options for every queue backend
| used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more.
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
| "deferred", "background", "failover", "null"
|
*/
'connections' => [
'sync' => [
'driver' => 'sync',
],
'database' => [
'driver' => 'database',
'connection' => env('DB_QUEUE_CONNECTION'),
'table' => env('DB_QUEUE_TABLE', 'jobs'),
'queue' => env('DB_QUEUE', 'default'),
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
'after_commit' => false,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
'queue' => env('BEANSTALKD_QUEUE', 'default'),
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
'block_for' => 0,
'after_commit' => false,
],
'sqs' => [
'driver' => 'sqs',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
'queue' => env('SQS_QUEUE', 'default'),
'suffix' => env('SQS_SUFFIX'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'after_commit' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null,
'after_commit' => false,
],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
],
/*
|--------------------------------------------------------------------------
| Job Batching
|--------------------------------------------------------------------------
|
| The following options configure the database and table that store job
| batching information. These options can be updated to any database
| connection and table which has been defined by your application.
|
*/
'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'job_batches',
],
/*
|--------------------------------------------------------------------------
| Failed Queue Jobs
|--------------------------------------------------------------------------
|
| These options configure the behavior of failed queue job logging so you
| can control how and where failed jobs are stored. Laravel ships with
| support for storing failed jobs in a simple file or in a database.
|
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
*/
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'),
'table' => 'failed_jobs',
],
];
+38
View File
@@ -0,0 +1,38 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Third Party Services
|--------------------------------------------------------------------------
|
| This file is for storing the credentials for third party services such
| as Resend, Postmark, AWS, and more. This file provides the de facto
| location for this type of information, allowing packages to have
| a conventional file to locate the various service credentials.
|
*/
'postmark' => [
'key' => env('POSTMARK_API_KEY'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
],
'ses' => [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
'slack' => [
'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
],
],
];
+233
View File
@@ -0,0 +1,233 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Session Driver
|--------------------------------------------------------------------------
|
| This option determines the default session driver that is utilized for
| incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice.
|
| Supported: "file", "cookie", "database", "memcached",
| "redis", "dynamodb", "array"
|
*/
'driver' => env('SESSION_DRIVER', 'database'),
/*
|--------------------------------------------------------------------------
| Session Lifetime
|--------------------------------------------------------------------------
|
| Here you may specify the number of minutes that you wish the session
| to be allowed to remain idle before it expires. If you want them
| to expire immediately when the browser is closed then you may
| indicate that via the expire_on_close configuration option.
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
/*
|--------------------------------------------------------------------------
| Session Encryption
|--------------------------------------------------------------------------
|
| This option allows you to easily specify that all of your session data
| should be encrypted before it's stored. All encryption is performed
| automatically by Laravel and you may use the session like normal.
|
*/
'encrypt' => env('SESSION_ENCRYPT', false),
/*
|--------------------------------------------------------------------------
| Session File Location
|--------------------------------------------------------------------------
|
| When utilizing the "file" session driver, the session files are placed
| on disk. The default storage location is defined here; however, you
| are free to provide another location where they should be stored.
|
*/
'files' => storage_path('framework/sessions'),
/*
|--------------------------------------------------------------------------
| Session Database Connection
|--------------------------------------------------------------------------
|
| When using the "database" or "redis" session drivers, you may specify a
| connection that should be used to manage these sessions. This should
| correspond to a connection in your database configuration options.
|
*/
'connection' => env('SESSION_CONNECTION'),
/*
|--------------------------------------------------------------------------
| Session Database Table
|--------------------------------------------------------------------------
|
| When using the "database" session driver, you may specify the table to
| be used to store sessions. Of course, a sensible default is defined
| for you; however, you're welcome to change this to another table.
|
*/
'table' => env('SESSION_TABLE', 'sessions'),
/*
|--------------------------------------------------------------------------
| Session Cache Store
|--------------------------------------------------------------------------
|
| When using one of the framework's cache driven session backends, you may
| define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores.
|
| Affects: "dynamodb", "memcached", "redis"
|
*/
'store' => env('SESSION_STORE'),
/*
|--------------------------------------------------------------------------
| Session Sweeping Lottery
|--------------------------------------------------------------------------
|
| Some session drivers must manually sweep their storage location to get
| rid of old sessions from storage. Here are the chances that it will
| happen on a given request. By default, the odds are 2 out of 100.
|
*/
'lottery' => [2, 100],
/*
|--------------------------------------------------------------------------
| Session Cookie Name
|--------------------------------------------------------------------------
|
| Here you may change the name of the session cookie that is created by
| the framework. Typically, you should not need to change this value
| since doing so does not grant a meaningful security improvement.
|
*/
'cookie' => env(
'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
),
/*
|--------------------------------------------------------------------------
| Session Cookie Path
|--------------------------------------------------------------------------
|
| The session cookie path determines the path for which the cookie will
| be regarded as available. Typically, this will be the root path of
| your application, but you're free to change this when necessary.
|
*/
'path' => env('SESSION_PATH', '/'),
/*
|--------------------------------------------------------------------------
| Session Cookie Domain
|--------------------------------------------------------------------------
|
| This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed.
|
*/
'domain' => env('SESSION_DOMAIN'),
/*
|--------------------------------------------------------------------------
| HTTPS Only Cookies
|--------------------------------------------------------------------------
|
| By setting this option to true, session cookies will only be sent back
| to the server if the browser has a HTTPS connection. This will keep
| the cookie from being sent to you when it can't be done securely.
|
*/
'secure' => env('SESSION_SECURE_COOKIE'),
/*
|--------------------------------------------------------------------------
| HTTP Access Only
|--------------------------------------------------------------------------
|
| Setting this value to true will prevent JavaScript from accessing the
| value of the cookie and the cookie will only be accessible through
| the HTTP protocol. It's unlikely you should disable this option.
|
*/
'http_only' => env('SESSION_HTTP_ONLY', true),
/*
|--------------------------------------------------------------------------
| Same-Site Cookies
|--------------------------------------------------------------------------
|
| This option determines how your cookies behave when cross-site requests
| take place, and can be used to mitigate CSRF attacks. By default, we
| will set this value to "lax" to permit secure cross-site requests.
|
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
| Supported: "lax", "strict", "none", null
|
*/
'same_site' => env('SESSION_SAME_SITE', 'lax'),
/*
|--------------------------------------------------------------------------
| Partitioned Cookies
|--------------------------------------------------------------------------
|
| Setting this value to true will tie the cookie to the top-level site for
| a cross-site context. Partitioned cookies are accepted by the browser
| when flagged "secure" and the Same-Site attribute is set to "none".
|
*/
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
/*
|--------------------------------------------------------------------------
| Session Serialization
|--------------------------------------------------------------------------
|
| This value controls the serialization strategy for session data, which
| is JSON by default. Setting this to "php" allows the storage of PHP
| objects in the session but can make an application vulnerable to
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
| Supported: "json", "php"
|
*/
'serialization' => 'json',
];
+1
View File
@@ -0,0 +1 @@
*.sqlite*
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
@@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Konfiguracja samego panelu (nie danych z AD): domyślne zaznaczenia w oknie
* resetu hasła, polityka / generator haseł, klucz szyfrujący atrybuty oraz
* pełna definicja atrybutów i kolumn listy użytkowników.
*
* Wcześniej trzymane w pliku `storage/app/panel_settings.json`; teraz w bazie
* (klucz -> wartość JSON), obsługiwane przez App\Services\PanelSettingsService.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('panel_settings', function (Blueprint $table) {
$table->string('key')->primary();
$table->json('value')->nullable();
$table->timestamps();
});
// Jednorazowy import istniejącej konfiguracji z pliku JSON (jeśli była).
// Pomijamy w testach - tam baza ma startować na wbudowanych domyślnych.
if (app()->runningUnitTests()) {
return;
}
$legacy = storage_path('app/panel_settings.json');
if (! is_file($legacy)) {
return;
}
$decoded = json_decode((string) file_get_contents($legacy), true);
if (! is_array($decoded) || $decoded === []) {
return;
}
DB::table('panel_settings')->updateOrInsert(
['key' => 'panel'],
[
'value' => json_encode($decoded, JSON_UNESCAPED_UNICODE),
'created_at' => now(),
'updated_at' => now(),
]
);
// Zostaw ślad, żeby plik nie był importowany ponownie ani mylący.
@rename($legacy, $legacy.'.migrated');
}
public function down(): void
{
Schema::dropIfExists('panel_settings');
}
};
@@ -0,0 +1,86 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Definicje atrybutów AD pokazywanych w panelu (kolumny listy użytkowników
* oraz pola w oknie profilu) dostają własną tabelę z kolumnami, zamiast tkwić
* w polu JSON `panel_settings` -> wiersz 'panel' -> 'attributes'.
*
* `position` wyznacza kolejność kolumn i pól. Zarządza tym
* App\Services\PanelSettingsService (odczyt: normalizacja + odtworzenie
* pozycji "na stałe"; zapis: saveAttributes()).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('panel_attributes', function (Blueprint $table) {
$table->id();
$table->string('key', 64)->unique();
$table->string('label', 191)->default('');
$table->string('ldap', 128)->default('');
$table->string('category', 32)->default('general');
$table->string('type', 32)->default('text');
$table->boolean('is_column')->default(false);
$table->unsignedInteger('position')->default(0)->index();
$table->timestamps();
});
// Przenieś istniejącą listę z wiersza 'panel' (jeśli istnieje) i usuń
// z niego 'attributes' oraz nieużywane już klucze konfiguracji.
if (! Schema::hasTable('panel_settings')) {
return;
}
$row = DB::table('panel_settings')->where('key', 'panel')->first();
if (! $row) {
return;
}
$value = json_decode((string) $row->value, true);
if (! is_array($value)) {
return;
}
$now = now();
$position = 0;
foreach ($value['attributes'] ?? [] as $attr) {
if (! is_array($attr) || (string) ($attr['key'] ?? '') === '') {
continue;
}
DB::table('panel_attributes')->updateOrInsert(
['key' => (string) $attr['key']],
[
'label' => (string) ($attr['label'] ?? $attr['key']),
'ldap' => (string) ($attr['ldap'] ?? ''),
'category' => (string) ($attr['category'] ?? 'general'),
'type' => (string) ($attr['type'] ?? 'text'),
'is_column' => filter_var($attr['column'] ?? false, FILTER_VALIDATE_BOOL),
'position' => $position++,
'created_at' => $now,
'updated_at' => $now,
]
);
}
unset($value['attributes'], $value['domains'], $value['users_table']);
DB::table('panel_settings')->where('key', 'panel')->update([
'value' => json_encode($value, JSON_UNESCAPED_UNICODE),
'updated_at' => $now,
]);
}
public function down(): void
{
Schema::dropIfExists('panel_attributes');
}
};
@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Reguły walidacji pól profilu użytkownika, konfigurowane per atrybut w
* zakładce "Atrybuty": wzorzec (regex, z ogranicznikami), znacznik "wymagane"
* oraz krótki tekst pomocy pokazywany pod polem.
*
* Dotyczą tylko atrybutów o typie edytowalnym - dla pozycji systemowych /
* wyliczanych / "na stałe" PanelSettingsService wymusza wartości puste.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('panel_attributes', function (Blueprint $table) {
$table->string('regex', 191)->nullable()->after('type');
$table->boolean('required')->default(false)->after('regex');
$table->string('help', 255)->default('')->after('required');
});
}
public function down(): void
{
Schema::table('panel_attributes', function (Blueprint $table) {
$table->dropColumn(['regex', 'required', 'help']);
});
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Dziennik zmian panelu: kto / kiedy / jaka akcja / na jakim obiekcie AD /
* z jakim powodem, plus opcjonalny diff (`changes`) i kontekst (`context` -
* np. liczba kont w akcji masowej, lista kroków offboardingu).
*
* To jest wyłącznie warstwa aplikacji - nie przechowujemy tu obiektów AD.
* Wypełniane przez App\Services\AuditLogger.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('panel_audit', function (Blueprint $table) {
$table->id();
$table->string('actor', 128)->index(); // sAMAccountName administratora
$table->string('actor_name', 191)->default('');
$table->string('action', 64)->index(); // slug, np. user.disable
$table->string('target_type', 32)->nullable();
$table->string('target', 191)->nullable()->index();
$table->text('target_dn')->nullable();
$table->text('reason')->nullable();
$table->json('changes')->nullable();
$table->json('context')->nullable();
$table->string('ip', 45)->nullable();
$table->timestamp('created_at')->nullable()->index();
});
}
public function down(): void
{
Schema::dropIfExists('panel_audit');
}
};
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
}
}
+32
View File
@@ -0,0 +1,32 @@
# =============================================================================
# Local / no-Traefik override.
#
# Copy to docker/compose.override.yaml (git-ignored) to run the stack without
# a reverse proxy - the app is published straight on a host port and the
# external `traefik_public` network is neutralised.
#
# cp docker/compose.override.yaml.template docker/compose.override.yaml
# docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
#
# Then open http://localhost:8080 (set APP_URL=http://localhost:8080 in .env).
# =============================================================================
services:
app:
ports:
- "8080:80"
# Drop the Traefik wiring for a local run.
networks:
- internal
labels: []
mariadb:
# Expose the DB on the host if you want to connect a GUI client.
ports:
- "3306:3306"
networks:
# Redefine the external network as a normal local one so Compose doesn't
# require a pre-existing `traefik_public`.
traefik_public:
external: false
+71
View File
@@ -0,0 +1,71 @@
name: adconsole
# =============================================================================
# Production-ish Compose stack: PHP/Apache app + MariaDB.
#
# Reverse proxy: this file assumes an external Traefik instance on the
# `traefik_public` network. If you don't use Traefik, drop in a local override
# that publishes a port instead - see docker/compose.override.yaml.template
# and docs/deployment.md.
#
# Run from the repo root:
# docker compose -f docker/compose.yaml up -d --build
# =============================================================================
services:
app:
build:
context: ..
dockerfile: docker/php/Dockerfile
container_name: adconsole_app
restart: unless-stopped
volumes:
- ..:/var/www/html
- app_storage:/var/www/html/storage
depends_on:
mariadb:
condition: service_healthy
networks:
- internal
- traefik_public
labels:
- traefik.enable=true
- traefik.docker.network=traefik_public
- traefik.http.routers.adconsole.rule=Host(`${APP_DOMAIN:-adconsole.localhost}`)
- traefik.http.routers.adconsole.entrypoints=websecure
- traefik.http.routers.adconsole.tls=true
# Uncomment and set your resolver to let Traefik obtain a certificate:
# - traefik.http.routers.adconsole.tls.certresolver=letsencrypt
- traefik.http.services.adconsole.loadbalancer.server.port=80
mariadb:
image: mariadb:11
container_name: adconsole_db
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-change-me-root}
MARIADB_DATABASE: ${DB_DATABASE:-adconsole}
MARIADB_USER: ${DB_USERNAME:-adconsole}
MARIADB_PASSWORD: ${DB_PASSWORD:-change-me}
MARIADB_AUTO_UPGRADE: "1"
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
networks:
- internal
volumes:
db_data:
app_storage:
networks:
internal:
# Provided by your Traefik deployment. Remove this (and the `traefik_public`
# entry + labels under `app`) if you use the local port-publishing override.
traefik_public:
external: true
+39
View File
@@ -0,0 +1,39 @@
FROM php:8.4-apache
# --- System packages + PHP extensions -------------------------------------
RUN apt-get update && apt-get install -y --no-install-recommends \
libcurl4-openssl-dev \
libonig-dev \
libzip-dev \
libldap2-dev \
unzip \
git \
&& docker-php-ext-install -j"$(nproc)" curl mbstring zip pdo pdo_mysql ldap opcache \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Sensible opcache defaults for production.
RUN { \
echo 'opcache.enable=1'; \
echo 'opcache.enable_cli=0'; \
echo 'opcache.memory_consumption=128'; \
echo 'opcache.max_accelerated_files=20000'; \
echo 'opcache.validate_timestamps=1'; \
echo 'opcache.revalidate_freq=60'; \
} > /usr/local/etc/php/conf.d/opcache.ini
# Apache: enable rewrite + serve from public/.
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
RUN a2enmod rewrite \
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
&& sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
# Composer (from the official image, no network installer).
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY docker/php/entrypoint.sh /usr/local/bin/adconsole-entrypoint
RUN chmod +x /usr/local/bin/adconsole-entrypoint
WORKDIR /var/www/html
ENTRYPOINT ["adconsole-entrypoint"]
CMD ["apache2-foreground"]
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
# First-run bootstrap for the ADconsole container. The project is bind-mounted,
# so a fresh `git clone` has no vendor/ and no APP_KEY yet - handle both here so
# `docker compose up` just works. Safe to run on every start (all steps are
# idempotent).
set -e
cd /var/www/html
if [ ! -f .env ]; then
echo "[adconsole] .env is missing - copy .env.example to .env and fill it in." >&2
exit 1
fi
# 1. PHP dependencies.
if [ ! -f vendor/autoload.php ]; then
echo "[adconsole] installing composer dependencies..."
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
fi
# 2. Application key.
if ! grep -qE '^APP_KEY=.+' .env; then
echo "[adconsole] generating APP_KEY..."
php artisan key:generate --force
fi
# 3. Writable paths (bind-mounted repo is owned by the host user).
chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true
# 4. Database schema + storage symlink.
php artisan migrate --force
php artisan storage:link 2>/dev/null || true
# 5. Framework caches (config/route/view/event). Refreshed on every boot.
php artisan optimize:clear
php artisan optimize
exec "$@"
+17
View File
@@ -0,0 +1,17 @@
# ADconsole documentation
| Doc | Read it when you want to… |
|---|---|
| [installation.md](installation.md) | Install on a bare-metal / VM host (Apache or nginx). |
| [configuration.md](configuration.md) | Understand every `.env` key and the in-app settings. |
| [active-directory.md](active-directory.md) | Set up the service account, delegation, LDAPS and the login group. |
| [architecture.md](architecture.md) | Understand how the app is put together and why. |
| [deployment.md](deployment.md) | Run it in production — Docker/Traefik, HTTPS, caches, upgrades. |
| [development.md](development.md) | Hack on it: local setup, tests, code style, translations. |
| [troubleshooting.md](troubleshooting.md) | Fix "the list is empty", "password reset fails", etc. |
| [upgrading.md](upgrading.md) | Pull a new version safely. |
| [publishing.md](publishing.md) | Turn this tree into a clean shared/public repo. |
Start with the [project README](../README.md) for the 5-minute overview.
Security policy and vulnerability reporting: [../SECURITY.md](../SECURITY.md).
+118
View File
@@ -0,0 +1,118 @@
# Active Directory setup
ADconsole needs three things from your directory: a **service account** to bind
with, an **encrypted channel** (LDAPS/STARTTLS), and a **login group** whose
members may use the panel. What the panel can actually *change* is governed
entirely by AD delegation on that service account — grant only what you need.
## 1. Service account
Create a dedicated account, e.g. `svc-adconsole`:
- Password never expires, **cannot** change password, not a member of any
privileged group by default.
- Deny interactive/RDP logon (it only needs "Access this computer from the
network" to the DCs, i.e. LDAP bind).
- Put its DN in `LDAP_USERNAME` and its password in `LDAP_PASSWORD`.
Test the bind:
```sh
php artisan tinker
>>> \LdapRecord\Container::getConnection('default')->connect(); // no exception = OK
```
## 2. Encrypted transport (LDAPS / STARTTLS)
AD refuses password writes (`unicodePwd`) and other sensitive changes over an
unencrypted connection, so this is mandatory for anything beyond read-only use.
- **LDAPS (recommended):** ensure the DCs have a certificate with Server
Authentication EKU (AD CS "Domain Controller" / "Kerberos Authentication"
template auto-enrols this). Set `LDAP_SSL=true`, `LDAP_PORT=636`.
- **STARTTLS:** `LDAP_TLS=true`, `LDAP_PORT=389`.
- If the app host does **not** trust the issuing CA, set `LDAP_TLS_VERIFY=false`
(skips chain validation — acceptable on a trusted network, not ideal). To
verify instead, install the internal root CA in the app host's trust store
(`/etc/ssl/certs`, `update-ca-certificates`) and set `LDAP_TLS_VERIFY=true`.
Quick check from the app host:
```sh
openssl s_client -connect dc01.corp.example.com:636 -showcerts </dev/null
```
## 3. Base DN and OU layout
`LDAP_BASE_DN` is used by **every** module. If your users, computers and groups
live in sibling OUs, the base DN must be an ancestor of all three — normally the
domain root:
```
LDAP_BASE_DN="DC=corp,DC=example,DC=com"
LDAP_DEFAULT_USER_OU="OU=Staff,DC=corp,DC=example,DC=com"
LDAP_DEFAULT_COMPUTER_OU="OU=Workstations,DC=corp,DC=example,DC=com"
LDAP_DEFAULT_GROUPS_OU="OU=Security Groups,DC=corp,DC=example,DC=com"
```
A too-narrow base DN is the #1 cause of "the computers / groups list is empty".
## 4. Login group
`AD_LOGIN_GROUP` = the group whose members may sign in. Use the full DN:
```
AD_LOGIN_GROUP="CN=ADconsole Admins,OU=Security Groups,DC=corp,DC=example,DC=com"
```
Nested membership and the primary group are honoured
(`App\Ldap\Rules\MemberOfLoginGroup`). Empty value = nobody can log in. This is
an application gate only — it grants no directory rights.
## 5. Delegation — grant per feature
Delegate on the **OUs you want ADconsole to manage** (Delegation of Control
Wizard, or `dsacls`). Map the features you'll use to the rights below and skip
the rest.
| Feature | Rights on the target OU(s) |
|---|---|
| Read lists, search, view profiles | Read all properties on user/computer/group objects (Authenticated Users usually have this already) |
| **Reset password** | *Reset Password* extended right on user objects |
| **Force "change at next logon"** | Write `pwdLastSet` |
| **Unlock account** | Write `lockoutTime` |
| **Enable / disable account** | Write `userAccountControl` |
| **Password-expiry toggle (never expires)** | Write `userAccountControl` |
| **Account expiry date** | Write `accountExpires` |
| **Create user** | *Create/Delete User objects*; Write the attributes your panel writes (name, `sAMAccountName`, `userPrincipalName`, `mail`, …) |
| **Delete user / computer / group** | *Delete* on that object class |
| **Move between OUs** | *Write* + *Delete Subtree* on source, *Create child* on destination |
| **Group membership add/remove** | Write `member` on the group objects |
| **Edit group description / mail** | Write `description`, `mail` |
| **Create group** | *Create Group objects* + write its attributes |
| **Reset computer trust account** | *Reset Password* extended right on computer objects |
| **Photo upload** | Write `thumbnailPhoto` |
| **LAPS password (read)** | Read `ms-Mcs-AdmPwd` / `msLAPS-Password` (+ `…-EncryptedPassword`). These are **not** readable by default — grant explicitly, e.g. `Set-AdmPwdReadPasswordPermission` / Windows LAPS ACL. |
| **BitLocker recovery keys (read)** | Read `msFVE-RecoveryInformation` child objects (`msFVE-RecoveryPassword`) |
| **GPO module** | Read on `CN=Policies,CN=System,<baseDN>` and read `gPLink` on OUs (default for Authenticated Users). No write is ever attempted. |
Example — password reset + unlock + enable/disable on one OU:
```powershell
dsacls "OU=Staff,DC=corp,DC=example,DC=com" /I:S `
/G "CORP\svc-adconsole:CA;Reset Password;user" `
"CORP\svc-adconsole:WP;lockoutTime;user" `
"CORP\svc-adconsole:WP;userAccountControl;user" `
"CORP\svc-adconsole:WP;pwdLastSet;user"
```
## 6. Verify
1. Sign in as a member of `AD_LOGIN_GROUP`.
2. Open **Users** — the list and OU tree populate.
3. Open a test account → **Reset password** → set a value → confirm on a real
logon. A failure here almost always means "no encrypted channel" or "Reset
Password right not delegated".
4. Repeat for the other features you delegated.
See [troubleshooting.md](troubleshooting.md) for specific errors.
+135
View File
@@ -0,0 +1,135 @@
# Architecture
## Principles
1. **The directory is the database.** No table mirrors AD. Controllers call
LdapRecord which talks to a DC per request. Nothing to sync, nothing to
reconcile, no stale copy.
2. **The SQL database is infrastructure only** — `sessions`, `cache`,
`jobs`/`job_batches`/`failed_jobs`, the panel's own configuration
(`panel_settings` for general settings, `panel_attributes` for the AD
attribute / column definitions) and the change log (`panel_audit`). None of
these mirror directory state. The default `users` / `password_reset_tokens`
tables from Laravel's starter migration are unused.
3. **Thin services, fat controllers-per-module.** One controller per screen; a
handful of small services for the cross-cutting bits.
## Request lifecycle
```
Browser
│ cookie session (file) ──► ad_admin profile
▼
routes/web.php (English URL segments and route names)
│ middleware: auth:ldap + App\Http\Middleware\SetLocale
▼
App\Http\Controllers\Admin\*Controller
│ LdapRecord query / model save
▼
Active Directory (LDAPS) Cache::store('file') ── OU tree / dashboard / org chart
```
## Authentication
- Guard `ldap` (config/auth.php) — LdapRecord "plain bind", **no** Eloquent user
provider, no `users` table.
- `LoginController@login` binds to AD with the submitted credentials, then runs
`App\Ldap\Rules\MemberOfLoginGroup`: the account must be a member of
`AD_LOGIN_GROUP` (accepts full DN or CN; walks nested groups; includes the
primary group).
- On success the sanitised profile from `App\Ldap\User::toPanelProfile()` is
written to the session key `ad_admin`; every panel route group is behind
`auth:ldap`.
- Logout clears the session. Sessions are `file`-driver by default.
## Key classes
| Class | Responsibility |
|---|---|
| `App\Ldap\User` | AD user model. `attributeMap()` (config/ldap.php → field names), `panelAttributes()` (the attribute list every user query selects), `toPanelProfile()`. |
| `App\Ldap\Rules\MemberOfLoginGroup` | Login authorisation. |
| `App\Services\PanelSettingsService` | The one entry point for panel config. General settings ← `panel_settings` (JSON row `panel`, merged with `defaults()`); attribute / column definitions ← `panel_attributes` (one row each, ordered by `position`, merged with `defaultAttributes()`), normalised with the locked rows enforced. |
| `App\Models\PanelSetting` / `App\Models\PanelAttribute` | Eloquent models for the two config tables. Don't read them from controllers — go through `PanelSettingsService`. |
| `App\Services\AttributeCipher` | Laravel-`Crypt`-compatible encrypt/decrypt for attributes typed *encrypted*; key from the panel (or a pasted `APP_KEY`). |
| `App\Services\LdapAttributeHelper` | Decodes `userAccountControl` bit flags, Windows FileTime ↔ Carbon, `accountExpires` / `pwdLastSet` "never" sentinels, derived status badge, `passwordExpiresAt()`. |
| `App\Services\OuTreeService` | Builds the sidebar OU tree (with object counts), cached on the `file` store; `flush()` on demand. |
| `App\Services\AuditLogger` | Writes one `panel_audit` row per panel action (actor from the `ldap` guard, action slug, target, optional reason + `changes`/`context` JSON). Best-effort: a write failure is `report()`ed, never propagated, so auditing can't break an AD operation. |
| `App\Http\Middleware\SetLocale` | Locale from session → cookie → `APP_LOCALE`. |
## Modules
| Route prefix | Controller | Notes |
|---|---|---|
| `/dashboard` | `DashboardController` | Streams every user with `chunk()`, aggregates in PHP, caches the result 5 min (`dashboard:summary` on the `file` store) with a manual refresh. |
| `/users` | `UserController` | List + OU tree + search; CRUD; password reset / OU move / expiry — single and **bulk**; `filter=` query for the dashboard tiles; CSV export of the matching list (`GET /users/export`, streamed, UTF-8 BOM, one column per configured attribute); **offboarding** (`offboardOne` / `bulkOffboard` → `runOffboard`: attribute changes → group removal → OU move, in that order because the move changes the DN). |
| `/computers` | `ComputerController` | List + OU tree; details include LAPS (legacy `ms-Mcs-AdmPwd` and Windows LAPS `msLAPS-Password`) and BitLocker (`msFVE-RecoveryInformation`); trust-account reset; enable/disable; delete. |
| `/groups` | `GroupController` | List with type/scope filter; create; edit description & mail; membership add/remove; delete. |
| `/organisation` | `OrgChartController` | `manager` → `directReports`; rendered client-side with d3-org-chart (CDN); cached 10 min. |
| `/gpo` | `GpoController` | Read-only: `groupPolicyContainer` objects, `gPLink` on OUs, simplified RSoP. |
| `/audit` | `AuditController` | Read-only view of `panel_audit` with filters (actor / action / object / date range). No write route. |
| `/settings` | `ConfigController` | General settings + attribute definitions → `PanelSettingsService`. |
## Audit log
Every write the panel performs against AD calls `AuditLogger::log($action,
$opts)`, which appends a row to `panel_audit`:
- `actor` / `actor_name` — the signed-in admin (`sAMAccountName` + `cn` from the
`ldap` guard; falls back to the `ad_admin` session profile, then `system`).
- `action` — a stable slug: `user.disable`, `user.reset_password`,
`user.offboard`, `user.bulk_move`, `computer.laps_view`,
`group.member_add`, `config.settings`, … (viewing a LAPS / BitLocker secret is
audited too).
- `target_type` / `target` / `target_dn` — the object acted on.
- `reason` — free text. Optional by default; the **Audit log** settings block has
a *"require a reason for destructive actions"* switch
(`panel_settings` → `audit.require_reason`) that makes it mandatory for
disable / delete / move / offboard. A row is written either way.
- `changes` / `context` — JSON: a before/after diff, and extras such as the
number of accounts in a bulk action or the list of offboarding steps run.
- `ip`, `created_at`.
The table is read only by `AuditController` (filter + paginate) and never by the
rest of the app. A failed insert is `report()`ed and swallowed — auditing can
never abort an AD operation. Nothing prunes the table; see
[deployment.md](deployment.md#data--backups) for retention.
## Caching
App-level caches use `Cache::store('file')` explicitly (independent of
`CACHE_STORE`):
| Key | TTL | Refresh |
|---|---|---|
| `ou_tree:{scope}` | 10 min | `?refresh_ou=1` in a module, or `OuTreeService::flush()` |
| `dashboard:summary` (+ `…_ts`) | 5 min | "Recalculate now" button (`POST /dashboard/refresh`) |
| `org_chart:v3` (+ `…_ts`) | 10 min | "Refresh" on the org chart |
Some user mutations also bust `org_chart:v3` and `dashboard:summary`.
## Front-end
- Bootstrap 5 + Bootstrap Icons from a CDN.
- `public/css/style.css` and `public/js/main.js` are **hand-written and served
directly** — there is no Vite build to run for the app to work. (`resources/js`,
`resources/css` and `vite.config.js` exist for anyone who wants to add a
bundled asset pipeline.)
- Light/dark theme via `data-bs-theme` on `<html>`, remembered in
`localStorage`.
## Internationalisation
- `lang/pl/*.php` and `lang/en/*.php`, structured by namespace
(`nav`, `users`, `computers`, `groups`, `gpo`, `dashboard`, `config`,
`common`, `auth`, `pages`, …). Access with `__('namespace.key')`.
- `config/locales.php` is the single list of available languages.
- Switchers on the login page, the navbar and the footer;
`GET /language/{locale}` (`LocaleController@switch`) persists the choice.
## What is intentionally *not* here
- No local mirror / cache of AD objects.
- No GPO **content** editing (SYSVOL over SMB).
- No background jobs or scheduler.
- No REST API (server-rendered Blade only; some endpoints return partial HTML or
JSON for the live-search / typeahead helpers).
+124
View File
@@ -0,0 +1,124 @@
# Configuration
Two layers:
1. **`.env`** — deployment settings, read at boot, never written back. After any
change run `php artisan config:cache` (or `php artisan optimize`), or the old
cached values keep being used.
2. **Database (`/settings`)** — in-app settings via `PanelSettingsService`:
general settings in `panel_settings` (one JSON row), the AD attribute /
column definitions in `panel_attributes` (one row each).
## `.env` reference
### Application
| Key | Default | Notes |
|---|---|---|
| `APP_NAME` | `ADconsole` | Shown in the header / page titles. |
| `APP_ENV` | `production` | `local` on a dev box only. |
| `APP_KEY` | — | `php artisan key:generate`. Also the fallback key for encrypted attributes (see below). **Rotating it invalidates existing sessions and any attributes encrypted with it.** |
| `APP_DEBUG` | `false` | Never `true` in production — stack traces leak DNs and query filters. |
| `APP_URL` | — | Exact public URL, scheme + host, no trailing slash. |
| `APP_DOMAIN` | — | Hostname the Docker/Traefik router matches. Ignored on bare metal. |
| `APP_LOCALE` | `pl` | `pl` or `en`. |
| `APP_FALLBACK_LOCALE` | `en` | Used for any missing translation key. |
| `LOG_CHANNEL` / `LOG_LEVEL` | `stack` / `warning` | Standard Laravel logging. |
### Database
ADconsole stores **no directory data** in SQL — only sessions, cache, queue, the
panel's own config (`panel_settings`, `panel_attributes`) and the change log
(`panel_audit`).
| Key | Notes |
|---|---|
| `DB_CONNECTION` | `mysql` (MariaDB/MySQL), `pgsql`, or `sqlite`. |
| `DB_HOST` / `DB_PORT` / `DB_DATABASE` / `DB_USERNAME` / `DB_PASSWORD` | Connection. |
| `DB_ROOT_PASSWORD` | Only consumed by `docker/compose.yaml` on first volume init. |
### Sessions / cache / queue
| Key | Default | Notes |
|---|---|---|
| `SESSION_DRIVER` | `file` | `database` or `redis` if you run more than one app node. |
| `SESSION_LIFETIME` | `120` | Minutes. |
| `CACHE_STORE` | `database` | The app's own heavy caches (OU tree, dashboard, org chart) always use the **`file`** store regardless of this. |
| `QUEUE_CONNECTION` | `database` | No queued jobs ship today. |
### Active Directory — connection
| Key | Default | Notes |
|---|---|---|
| `LDAP_HOSTS` | `127.0.0.1` | Comma-separated DC list, tried in order. `LDAP_HOST` (singular) also accepted. |
| `LDAP_USERNAME` | — | Service-account bind DN or `user@domain`. |
| `LDAP_PASSWORD` | — | Service-account password. |
| `LDAP_PORT` | `636` | `636` LDAPS, `389` plain/STARTTLS. |
| `LDAP_SSL` | `true` | LDAPS. |
| `LDAP_TLS` | `false` | STARTTLS on 389. Use **one** of SSL/TLS. |
| `LDAP_TLS_VERIFY` | `false` | `true` = validate the DC certificate chain. Leave `false` for an internal-CA / self-signed cert the app host doesn't trust. |
| `LDAP_TIMEOUT` | `5` | Seconds. |
| `LDAP_BASE_DN` | — | **Must cover users, computers and groups.** If those OUs are siblings, use the domain root (`DC=…,DC=…`). |
| `LDAP_LOGGING` | `true` | Logs LDAP operations to the Laravel log. |
> Writes to AD (password reset, enable/disable, create, move, delete) **require**
> `LDAP_SSL=true` or `LDAP_TLS=true`. A clear connection can read but every write
> of `unicodePwd` etc. is refused by the DC.
### Active Directory — defaults & access
| Key | Notes |
|---|---|
| `LDAP_DEFAULT_USER_OU` | Where new users are created; also caps the Users OU tree. |
| `LDAP_DEFAULT_COMPUTER_OU` | Same for Computers. |
| `LDAP_DEFAULT_GROUPS_OU` | Same for Groups. |
| `AD_DOMAIN_LABEL` | Friendly domain name in the header (defaults to `LDAP_BASE_DN`). |
| `AD_LOGIN_GROUP` | AD group whose members may sign in. Full DN (recommended) or bare CN. Nested membership + primary group honoured. **Empty = nobody can log in.** |
`AD_LOGIN_GROUP` controls *who can open the panel*. It does **not** grant
directory permissions — what the service account may change is decided by AD
delegation. See [active-directory.md](active-directory.md).
### Mail
Not used by the app as shipped. Configure `MAIL_*` only if you add notifications.
## In-app settings (`/settings`)
All read and written through `App\Services\PanelSettingsService`. Nothing here
touches `.env`.
### General tab
Stored as one JSON row in **`panel_settings`** (key `panel`). Absent → the
built-in `PanelSettingsService::defaults()` apply.
| Setting | Meaning |
|---|---|
| Reset password → *must change at next logon* | Default state of that checkbox in the reset dialog. |
| Reset password → *unlock account* | Default state of the "also unlock" checkbox. |
| Password policy → *max age (days)* | Drives the "password expires / expired" columns and dashboard tiles. `0` = no expiry. This is ADconsole's own figure — it does not read the domain's `maxPwdAge`. |
| Password generator → *length* / *symbols* | For the in-panel generator only; does not touch AD policy. |
| Attribute encryption key | Symmetric key used to encrypt/decrypt attributes whose type is *encrypted* (e.g. cloud extension attributes). Empty = feature off. `base64:…` (16/32 bytes) is used verbatim — paste `APP_KEY` here if values were encrypted with it; any other string is hashed to a 32-byte key. See `App\Services\AttributeCipher`. |
| Audit log → *require a reason for destructive actions* | When on, disable / delete / move-OU / offboard refuse to run without a reason. An audit row (`panel_audit`) is written either way — this only forces the operator to type why. Default: off. |
### Attributes tab
Defines the attribute set the Users module works with. One row per attribute in
**`panel_attributes`**; row order (`position`) sets column / field order. Empty
table → `PanelSettingsService::defaultAttributes()` apply.
| Column | Meaning |
|---|---|
| `key` | internal id (`[A-Za-z0-9_-]`) |
| `label` | column / field caption |
| `ldap` | the AD attribute name |
| `category` | profile-dialog tab: `general` / `org` / `address` / `hr` / `account` |
| `type` | `text` · `number` · `date` · `encrypted` (editable) or `system` · `account_status` · `password_expiry` (computed / read-only) |
| `is_column` | show it as a column on the user list |
| `position` | display order |
Locked rows that can't be removed or retyped: `login` (sAMAccountName),
`distinguishedName`, the computed `status` and `password_expiry`, and
`account_expiry` (fixed `date`, `accountExpires`). If deleted directly in SQL
they are re-created from the defaults on the next read.
+97
View File
@@ -0,0 +1,97 @@
# Deployment (Docker)
The repo ships a small production stack in `docker/`:
- `docker/php/Dockerfile` — PHP 8.4 + Apache, `ldap`/`pdo_mysql`/`opcache`,
Composer, and an entrypoint that self-bootstraps on first boot.
- `docker/php/entrypoint.sh` — installs `vendor/` if missing, generates
`APP_KEY`, fixes `storage/` ownership, runs `migrate --force`, warms caches,
then starts Apache. Idempotent — runs on every start.
- `docker/compose.yaml` — `app` + `mariadb`, wired for an **external Traefik**
proxy on the `traefik_public` network.
- `docker/compose.override.yaml.template` — copy to
`docker/compose.override.yaml` (git-ignored) to run **without** Traefik by
publishing a host port.
## With Traefik
```sh
cp .env.example .env
# APP_ENV=production, APP_DEBUG=false
# APP_URL=https://adconsole.example.com
# APP_DOMAIN=adconsole.example.com <- Traefik Host() rule
# DB_PASSWORD / DB_ROOT_PASSWORD <- set real values
# LDAP_* / AD_* <- your domain
docker compose -f docker/compose.yaml up -d --build
docker compose -f docker/compose.yaml logs -f app # watch the bootstrap
```
Enable a certificate resolver by uncommenting the `certresolver` label in
`docker/compose.yaml` and setting its name to match your Traefik config.
The `.env` file is **bind-mounted**, not baked in: edit it and
`docker compose restart app` — no rebuild needed. (This is deliberate; see the
comment in `compose.yaml`.)
## Without Traefik
```sh
cp docker/compose.override.yaml.template docker/compose.override.yaml
# APP_URL=http://localhost:8080 in .env
docker compose -f docker/compose.yaml -f docker/compose.override.yaml up -d --build
```
App on `http://localhost:8080`, MariaDB on `localhost:3306`. Put a TLS-terminating
proxy (nginx, Caddy, your load balancer) in front for anything but a lab.
## Data & backups
| What | Where | Back up? |
|---|---|---|
| Database (`panel_settings` + `panel_attributes` config, `panel_audit` change log, sessions, cache) | `db_data` volume | **Yes** — `panel_settings` / `panel_attributes` are your config and `panel_audit` is your only record of who changed what. `mysqldump adconsole`. |
| `storage/` (logs, framework cache, sessions if file-driver) | `app_storage` volume | Optional (logs only really). |
| `.env` | bind-mounted from the repo | **Yes**, securely — it holds the service-account password. |
Restore config elsewhere: `mysqldump`/import the `panel_settings` and
`panel_attributes` tables, or re-enter it in `/settings`.
**Audit-log retention.** `panel_audit` grows with panel activity and nothing
prunes it automatically. If you need a retention window, trim it with a scheduled
job of your own, e.g.
`DELETE FROM panel_audit WHERE created_at < NOW() - INTERVAL 365 DAY;`, and
back it up (or ship it to a SIEM) before trimming if the entries have
compliance value.
## Upgrades
```sh
git pull
docker compose -f docker/compose.yaml up -d --build
```
The entrypoint runs `migrate --force` and `optimize` on the new container. See
[upgrading.md](upgrading.md) for version-specific notes.
## Hardening checklist
- [ ] `APP_DEBUG=false`, `APP_ENV=production`
- [ ] HTTPS only; HSTS at the proxy
- [ ] `LDAP_SSL=true` (or `LDAP_TLS=true`); `LDAP_TLS_VERIFY=true` once the CA is
trusted by the app host
- [ ] Service account delegated **only** the rights you use ([active-directory.md](active-directory.md))
- [ ] `AD_LOGIN_GROUP` is a small, audited group
- [ ] Panel reachable only from admin networks / VPN (proxy ACL or firewall)
- [ ] DB not exposed to the internet; strong `DB_PASSWORD` / `DB_ROOT_PASSWORD`
- [ ] `.env` file permissions `600`, owned by the deploy user
- [ ] Log shipping / retention configured; review `LDAP_LOGGING` volume
- [ ] Backups of the database tested
## Scaling notes
- Multiple app containers: move `SESSION_DRIVER` and the app caches off local
disk (`database`/`redis`) — note the OU-tree/dashboard/org-chart caches are
hard-coded to the `file` store, so with >1 node each node keeps its own (they
expire in minutes; acceptable, or add a shared store and change
`Cache::store('file')` call sites).
- One `mariadb` is plenty; the workload is tiny.
+99
View File
@@ -0,0 +1,99 @@
# Development
## Local setup
```sh
git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git adconsole && cd adconsole
composer install
cp .env.example .env
php artisan key:generate
```
Edit `.env` for a dev box:
```
APP_ENV=local
APP_DEBUG=true
LOG_LEVEL=debug
DB_CONNECTION=sqlite # simplest; then: touch database/database.sqlite
```
```sh
php artisan migrate
php artisan serve
```
You still need a reachable domain controller for the AD-backed screens. Without
one, the auth and list pages will error — the test suite does **not** need a DC
(it fakes the guard and mocks LdapRecord).
## Tests
```sh
php artisan test # everything (~40, SQLite :memory:)
php artisan test --filter=Dashboard # one file / group
composer test # config:clear + test
```
- `tests/Unit` — pure logic (`LdapAttributeHelper`, `AttributeCipher`,
`MemberOfLoginGroup`, `OuTreeService`).
- `tests/Feature` — HTTP: auth, localisation, the About page, attribute config,
the audit log and the user CSV export (`RefreshDatabase`), and the dashboard
(seeds the `file` cache, no LDAP).
- `tests/TestCase::actingAsPanelAdmin()` logs in a fake `App\Ldap\User` on the
`ldap` guard so you can hit protected routes.
PHPUnit tags every test `DEPR` because PHP 8.5 deprecates a PDO constant PHPUnit
touches. It is **not** a failure — look at the `Tests:` summary line.
## Code style
[Laravel Pint](https://laravel.com/docs/pint):
```sh
vendor/bin/pint # fix
vendor/bin/pint --test # check only (CI)
composer lint / composer format
```
Match the surrounding code: constructor property promotion, typed properties,
short closures, `App\Services` for cross-cutting logic. Comments in the codebase
are Polish — keep new ones consistent with the file you're editing.
## Adding a translation string
1. Add the key to **both** `lang/pl/<ns>.php` and `lang/en/<ns>.php`.
2. Use `__('<ns>.<key>')` in Blade/PHP — never inline copy.
3. Some existing `lang/pl/*` values are asserted verbatim by
`tests/Feature/AttributeConfigTest` and `AboutPageTest`; run the suite after
touching PL strings.
## Adding an AD attribute to the UI
No code change needed for a plain attribute: `/settings` → **Attributes** →
add key / label / `ldap` name / category / type / column. Computed or
specially-encoded attributes (bit flags, FileTime) need a helper in
`App\Services\LdapAttributeHelper` and wiring in the relevant controller.
## Front-end
`public/css/style.css` and `public/js/main.js` are edited directly and served
as-is — no build step. Keep the light/dark (`data-bs-theme`) treatment working
for anything new: define colours as tokens on `:root` and override under
`[data-bs-theme="dark"]`.
## Database changes
New migrations go in `database/migrations`. Remember the project rule: **no AD
data in SQL** — migrations are for app infrastructure and `panel_settings`-style
config only. `PanelSettingsService` is the access point for panel config; don't
read the table directly from controllers.
## Handy commands
```sh
php artisan route:list --path=users
php artisan tinker
php artisan optimize:clear # after .env / config edits
php artisan migrate:fresh # dev DB reset (infra tables only)
```
+141
View File
@@ -0,0 +1,141 @@
# Installation (bare metal / VM)
For the containerised path see [deployment.md](deployment.md); this page covers a
classic PHP host.
## 1. Prerequisites
- PHP **8.2+** with extensions: `ldap`, `pdo_mysql` (or `pdo_pgsql` / `pdo_sqlite`),
`mbstring`, `curl`, `zip`, `openssl`, `fileinfo`, `ctype`, `json`
- Composer 2
- A web server (Apache with `mod_rewrite`, or nginx + PHP-FPM)
- A database server — MariaDB 10.6+ / MySQL 8 recommended
- Outbound access to a domain controller on **636/tcp** (LDAPS) or **389/tcp**
(STARTTLS)
Check the LDAP extension:
```sh
php -m | grep -i ldap
```
## 2. Get the code
```sh
git clone https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git /var/www/adconsole
cd /var/www/adconsole
composer install --no-dev --optimize-autoloader
```
## 3. Environment
```sh
cp .env.example .env
php artisan key:generate
```
Edit `.env` — at minimum:
- `APP_URL` — the exact public URL (scheme + host, no trailing slash)
- `APP_ENV=production`, `APP_DEBUG=false`
- `DB_*` — database connection
- `LDAP_HOSTS`, `LDAP_USERNAME`, `LDAP_PASSWORD`, `LDAP_BASE_DN`
- `LDAP_SSL=true` (or `LDAP_TLS=true`), `LDAP_TLS_VERIFY`
- `LDAP_DEFAULT_USER_OU` / `_COMPUTER_OU` / `_GROUPS_OU`, `AD_DOMAIN_LABEL`
- `AD_LOGIN_GROUP` — the AD group whose members may sign in
Every key is explained in [configuration.md](configuration.md).
## 4. Database
Create the schema (sessions, cache, queue, `panel_settings`, `panel_attributes`,
`panel_audit`):
```sh
php artisan migrate --force
```
## 5. Caches & storage
```sh
php artisan storage:link
php artisan config:cache
php artisan route:cache
php artisan view:cache
```
Make `storage/` and `bootstrap/cache/` writable by the web-server user:
```sh
chown -R www-data:www-data storage bootstrap/cache
find storage bootstrap/cache -type d -exec chmod 775 {} \;
```
Re-run the three `*:cache` commands (or `php artisan optimize`) after **any**
`.env` change.
## 6. Web server
**Document root must be `public/`.** Never expose the project root.
### Apache
```apache
<VirtualHost *:443>
ServerName adconsole.example.com
DocumentRoot /var/www/adconsole/public
<Directory /var/www/adconsole/public>
AllowOverride All
Require all granted
</Directory>
SSLEngine on
SSLCertificateFile /etc/ssl/certs/adconsole.pem
SSLCertificateKeyFile /etc/ssl/private/adconsole.key
ErrorLog ${APACHE_LOG_DIR}/adconsole-error.log
CustomLog ${APACHE_LOG_DIR}/adconsole-access.log combined
</VirtualHost>
```
`a2enmod rewrite ssl && systemctl reload apache2`. Laravel ships the matching
`public/.htaccess`.
### nginx + PHP-FPM
```nginx
server {
listen 443 ssl;
server_name adconsole.example.com;
root /var/www/adconsole/public;
index index.php;
ssl_certificate /etc/ssl/certs/adconsole.pem;
ssl_certificate_key /etc/ssl/private/adconsole.key;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* { deny all; }
}
```
## 7. First login
Open `APP_URL`, pick a language, sign in with an AD account that belongs to
`AD_LOGIN_GROUP`. If it rejects you, see
[troubleshooting.md](troubleshooting.md#i-cannot-log-in).
## 8. Scheduler / queue (optional)
ADconsole has no cron jobs or queued work today, so no `schedule:run` or
`queue:work` is required. `QUEUE_CONNECTION=database` is a safe default if you
add async work later.
+97
View File
@@ -0,0 +1,97 @@
# Publishing ADconsole to a Git host
How this working tree was turned into the public repository, and the checklist to
follow if you re-publish it somewhere else (a fork, a mirror, a different host).
**Canonical repository:** <https://gitea.kzbikowski.pl/kzbkowski/ADconsle>
(self-hosted Gitea). The first public tag is **`v1.0.0`**.
## 1. Placeholders — all filled for the canonical repo
If you re-publish under a different owner/host, revisit these:
| File | Was | Now / put |
|---|---|---|
| `README.md`, `docs/installation.md`, `docs/development.md` | `<your-fork-url>` in the `git clone` lines | `https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git` |
| `SECURITY.md` | `<SET A SECURITY CONTACT HERE>` | `mail@kzbikowski.pl` |
| `.github/ISSUE_TEMPLATE/config.yml` | `https://github.com/OWNER/REPO/...` | The repo's Gitea issue/wiki URLs |
| `LICENSE` | `2026 Kacper Żbikowski` | Copyright year + holder, if different |
| `lang/{pl,en}/pages.php` → `author.*` | `Kacper Żbikowski` | Author/maintainer shown on the in-app *About* page |
| `composer.json` | `adconsole/adconsole` | Your package name (optional; nothing depends on it) |
`tests/Feature/Pages/AboutPageTest.php` asserts the author name and
`Copyright (c) <year> <name>` — if you change `pages.php` `author`, update that
test too.
## 2. Confirm nothing sensitive is tracked
Everything below is already covered by `.gitignore` / `.dockerignore` — verify
none of it slipped in:
- `.env`, `.env.backup`, `.env.production`, `.env.*.local`
- `storage/app/panel_settings.json` and `*.migrated`
- `vendor/`, `node_modules/`, `public/build/`, `public/hot/`
- `storage/` runtime (logs, sessions, framework cache) — only the `.gitignore`
keep-files should be tracked
- `docker/compose.override.yaml`
- IDE / agent dirs (`.idea/`, `.vscode/`, `.claude/`, …)
```sh
# Dry-run: what would a fresh repo add?
git init -q _probe && git --git-dir=_probe/.git --work-tree=. add -An | grep -Ei '(^add ...env$|vendor/|node_modules/|storage/(logs|framework)/.+/|panel_settings\.json)'
rm -rf _probe
# No output = clean.
```
Also grep for lab-specific values before the first push:
```sh
grep -rniE 'YOUR-DOMAIN|internal-hostname|real-ip' --exclude-dir=vendor --exclude-dir=node_modules .
```
## 3. Green build
```sh
composer install
cp .env.example .env && php artisan key:generate
php artisan migrate # against a throwaway SQLite/MySQL
php artisan test # 40+ pass (the DEPR tag is noise)
vendor/bin/pint --test # style clean
```
## 4. Create the repository
This tree started life inside an unrelated parent checkout, so it is published as
a fresh repo scoped to just this folder:
```sh
cd /path/to/adconsole
git init -b main
git add .
git status # sanity-check the file list
git commit -m "ADconsole v1.0.0"
git remote add origin https://gitea.kzbikowski.pl/kzbkowski/ADconsle.git
git push -u origin main
git tag -a v1.0.0 -m "ADconsole v1.0.0"
git push origin v1.0.0
```
## 5. On the Git host
- Set the repository **description** and topics: `active-directory`, `ldap`,
`laravel`, `ldaprecord`, `admin-panel`, `self-hosted`.
- Enable **Issues**. Point the "Question / help" contact link at the repo's
issues (Gitea has no Discussions).
- The GitHub-format templates in `.github/` (issue/PR templates, the
Pint + PHPUnit CI workflow on PHP 8.2/8.3/8.4) are read by Gitea Actions too if
Actions is enabled on the instance; otherwise they are harmless.
- Cut a **release** from the `v1.0.0` tag and paste the matching `CHANGELOG.md`
section as the notes.
## 6. First-deploy sanity (for whoever installs it)
Point them at [`installation.md`](installation.md) (bare metal) or
[`deployment.md`](deployment.md) (Docker), and
[`active-directory.md`](active-directory.md) for the service account and
delegation. [`troubleshooting.md`](troubleshooting.md) covers the usual
first-run stumbles.
+93
View File
@@ -0,0 +1,93 @@
# Troubleshooting
Turn on detail while diagnosing (dev/staging only):
```
APP_DEBUG=true
LOG_LEVEL=debug
LDAP_LOGGING=true
```
then `php artisan optimize:clear` and watch `storage/logs/laravel.log`.
---
## I cannot log in
- **"These credentials do not match our records" for a valid account** — the
account is not in `AD_LOGIN_GROUP`. Check the value is a correct **DN or CN**;
nested membership and the primary group count. Empty `AD_LOGIN_GROUP` = nobody
can log in.
- **Every login fails, including known-good** — the service account bind itself
is failing (wrong `LDAP_USERNAME`/`LDAP_PASSWORD`, unreachable
`LDAP_HOSTS`, or TLS handshake). See the LDAP lines in the log.
- **"Can't contact LDAP server"** — DNS/routing to `LDAP_HOSTS`, firewall on
636/389, or a TLS problem (below).
## TLS / certificate errors
- `LDAP_TLS_VERIFY=true` but the DC cert chains to an internal CA the app host
doesn't trust → install the root CA (`/usr/local/share/ca-certificates/`,
`update-ca-certificates`) or set `LDAP_TLS_VERIFY=false`.
- Mixed settings: use **either** `LDAP_SSL=true` + port 636 **or**
`LDAP_TLS=true` + port 389, not both.
- Verify from the host: `openssl s_client -connect dc01.example.com:636`.
## The users / computers / groups list is empty
1. **Base DN too narrow.** `LDAP_BASE_DN` must be an ancestor of the users,
computers **and** groups OUs. If they're siblings, use the domain root.
2. **Default OU wrong.** `LDAP_DEFAULT_*_OU` points somewhere with no objects —
the tree opens there. Try the "whole domain" (globe) button in the module.
3. **Stale cache** after fixing `.env` — click the refresh/globe control, or
`php artisan optimize:clear`, or wait out the 10-minute OU-tree TTL.
4. **Config cache** — you edited `.env` but didn't re-run `php artisan config:cache`.
## Password reset fails
- Almost always **no encrypted channel** — set `LDAP_SSL=true` (or
`LDAP_TLS=true`). AD refuses `unicodePwd` over clear LDAP.
- **"insufficient access rights"** — the service account lacks the *Reset
Password* extended right on that OU. See
[active-directory.md](active-directory.md#5-delegation--grant-per-feature).
- **"constraint violation"** — the new password violates the domain password
policy (length/complexity/history). ADconsole only checks `min:8` locally.
## Enable/disable or unlock does nothing
Write permission on `userAccountControl` (enable/disable, "never expires") or
`lockoutTime` (unlock) not delegated. The button returns without error but the
DC rejects the write — check the log.
## LAPS / BitLocker fields are blank
Those attributes are **not** world-readable. Grant the service account read on
`ms-Mcs-AdmPwd` / `msLAPS-Password` (LAPS) and on the
`msFVE-RecoveryInformation` child objects (BitLocker). Blank ≠ error — it means
"not readable" or "not present on this computer".
## GPO list is empty
The service account needs read on `CN=Policies,CN=System,<baseDN>`. Authenticated
Users usually have it; a locked-down forest may not.
## Dashboard numbers look wrong / stale
- The dashboard is cached 5 minutes. Hit **Recalculate now**.
- A **yellow age badge** = the cache is older than one refresh cycle; background
recalculation isn't running (an LDAP error during rebuild — check the log).
- Counts stream every matching object with `chunk()`, so they are not capped;
if a number seems low, verify the base DN and that the objects aren't filtered
out (disabled users are still counted in totals).
## 500 error after deploy / upgrade
- `php artisan migrate --force` not run → missing `panel_settings` table.
- `APP_KEY` not set → `php artisan key:generate`.
- Stale cache → `php artisan optimize:clear && php artisan optimize`.
- `storage/` / `bootstrap/cache/` not writable by the web user.
## `php artisan test` shows "DEPR" on every test
Expected. PHP 8.5 deprecates `PDO::MYSQL_ATTR_SSL_CA`, which PHPUnit references.
Read the `Tests: … ` summary line — `deprecated` there is not `failed`.
+72
View File
@@ -0,0 +1,72 @@
# Upgrading
## Versioning
From `v1.0.0` on, releases are [semantic](https://semver.org/) git tags
`vX.Y.Z`:
- **MAJOR** — incompatible change: a required new `.env` key, a schema change
that is not auto-migrated, or a new AD permission the service account needs.
Read this page before upgrading.
- **MINOR** — new, backwards-compatible feature. Safe to pull; still run
`php artisan migrate`.
- **PATCH** — backwards-compatible fix only.
The latest tag on `main` is the supported version; there are no long-term-support
branches. Per-version notes are under [Version notes](#version-notes) below and
in [`CHANGELOG.md`](../CHANGELOG.md).
## General procedure
```sh
# back up the DB first (panel_settings + panel_attributes hold your config)
mysqldump adconsole > adconsole-$(date +%F).sql
git pull # or fetch a new release
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan optimize:clear
php artisan optimize # config + route + view + event cache
```
Docker: `docker compose -f docker/compose.yaml up -d --build` — the entrypoint
runs `migrate --force` and `optimize` for you.
Roll back = restore the DB dump and check out the previous revision.
## Version notes
### Panel configuration moved into the database
Two migrations, both automatic — just run `php artisan migrate` (the Docker
entrypoint does this for you).
1. **`create_panel_settings_table`** — general settings (password policy &
generator, reset-dialog defaults, encryption key) move out of the old
`storage/app/panel_settings.json`. The migration **imports that file if
present** and renames it `panel_settings.json.migrated`.
2. **`create_panel_attributes_table`** — the AD attribute / column definitions
move from the JSON blob into their own table, one row per attribute with
real columns (`key`, `label`, `ldap`, `category`, `type`, `is_column`,
`position`). The migration copies the existing list across and strips the
now-unused `attributes` (and dead `domains` / `users_table`) keys from the
`panel_settings` row.
- After migrating, open `/settings` and confirm the **General** tab and the
**Attributes** tab still show your settings, then delete any
`storage/app/panel_settings.json.migrated`.
- Fresh installs have empty tables and run on `PanelSettingsService::defaults()`
/ `defaultAttributes()` until you save something.
- The legacy file and its `.migrated` rename are git-ignored.
### New `.env` keys
`LDAP_SSL`, `LDAP_TLS_VERIFY`, `APP_DOMAIN`. Existing installs keep working with
the defaults (`LDAP_SSL=true`, `LDAP_TLS_VERIFY=false`); add explicit values when
you next edit `.env`. See [configuration.md](configuration.md).
## After any upgrade
- `php artisan test` if you carry local patches.
- Re-check the [hardening checklist](deployment.md#hardening-checklist).
- Watch `storage/logs/laravel.log` on the first real use.
+82
View File
@@ -0,0 +1,82 @@
<?php
return [
'title' => 'Audit log',
'heading' => 'Audit log',
'subtitle' => 'Who changed what in Active Directory through the panel, and when.',
'filter' => [
'actor' => 'Administrator',
'action' => 'Action',
'action_all' => 'All actions',
'target' => 'Object (login / CN / DN)',
'from' => 'From',
'to' => 'To',
'apply' => 'Filter',
'clear' => 'Clear',
],
'col' => [
'when' => 'Date and time',
'actor' => 'Administrator',
'action' => 'Action',
'target' => 'Object',
'reason' => 'Reason',
],
'details' => 'Details',
'changes' => 'Changes',
'context' => 'Context',
'target_type' => [
'user' => 'user',
'computer' => 'computer',
'group' => 'group',
],
'actions' => [
'config.settings' => 'Panel settings',
'config.attributes' => 'Attribute configuration',
'user.create' => 'User created',
'user.update' => 'User edited',
'user.reset_password' => 'Password reset',
'user.password_expiry' => 'Password / account expiry',
'user.move' => 'Moved to OU',
'user.enable' => 'Account enabled',
'user.disable' => 'Account disabled',
'user.unlock' => 'Account unlocked',
'user.force_password_change' => 'Forced password change',
'user.delete' => 'User deleted',
'user.photo' => 'Photo changed',
'user.group_add' => 'Added to group',
'user.group_remove' => 'Removed from group',
'user.bulk_reset_password' => 'Bulk password reset',
'user.bulk_move' => 'Bulk move to OU',
'user.bulk_password_expiry' => 'Bulk password expiry change',
'user.bulk_enable' => 'Bulk account enable',
'user.bulk_disable' => 'Bulk account disable',
'user.bulk_group' => 'Bulk group membership change',
'user.bulk_force_password_change' => 'Bulk forced password change',
'user.bulk_unlock' => 'Bulk account unlock',
'user.bulk_attributes' => 'Bulk attribute edit',
'user.offboard' => 'Account offboarding',
'computer.enable' => 'Computer enabled',
'computer.disable' => 'Computer disabled',
'computer.move' => 'Computer moved to OU',
'computer.delete' => 'Computer deleted',
'computer.reset_trust' => 'Trust account reset',
'computer.laps_view' => 'LAPS password viewed',
'computer.bitlocker_view' => 'BitLocker keys viewed',
'computer.bulk_enable' => 'Bulk computer enable',
'computer.bulk_disable' => 'Bulk computer disable',
'computer.bulk_move' => 'Bulk computer move',
'group.create' => 'Group created',
'group.update' => 'Group edited',
'group.delete' => 'Group deleted',
'group.member_add' => 'Group member added',
'group.member_remove' => 'Group member removed',
],
];
+26
View File
@@ -0,0 +1,26 @@
<?php
return [
// Login screen
'title' => 'Sign in',
'heading' => 'Sign in with your domain account',
'login_label' => 'Login',
'password_label' => 'Password',
'submit' => 'Sign in',
'access_note' => 'Access is restricted to members of the AD group authorised for this panel.',
// Field names used in validation messages
'attributes' => [
'login' => 'login',
'password' => 'password',
],
// LoginController messages
'failed_dc' => 'Could not reach a domain controller. Please try again in a moment.',
'failed_credentials' => 'Invalid login or password, or the account is not authorised for this panel.',
'throttled' => 'Too many failed sign-in attempts. Please try again in :seconds s.',
'logged_out' => 'You have been signed out.',
// User menu after sign-in
'logout' => 'Sign out',
];
+60
View File
@@ -0,0 +1,60 @@
<?php
return [
// Buttons / actions
'save' => 'Save',
'save_changes' => 'Save changes',
'cancel' => 'Cancel',
'close' => 'Close',
'reason' => 'Reason',
'reason_placeholder' => 'Short justification (recorded in the audit log)',
'add' => 'Add',
'delete' => 'Delete',
'edit' => 'Edit',
'refresh' => 'Refresh',
'search' => 'Search',
'check' => 'Check',
'columns' => 'Columns',
'recalculate' => 'Recalculate',
'recalculate_now' => 'Recalculate now',
'actions' => 'Actions',
// Reusable headings / labels
'name' => 'Name',
'status' => 'Status',
'description' => 'Description',
'type' => 'Type',
'yes' => 'yes',
'no' => 'no',
'none_dash' => '—',
// Account status (user / computer tables)
'account_status' => [
'active' => 'Active',
'disabled' => 'Disabled',
'locked' => 'Locked out',
],
// States
'loading' => 'Loading...',
'saving' => 'Saving...',
'checking' => 'Checking...',
'load_error' => 'Failed to load.',
'error' => 'Error.',
'no_items' => 'No items.',
'search_all_fields' => 'Search all fields...',
// OU tree
'ou_show_whole_domain' => 'Show the whole domain',
'ou_refresh_tree' => 'Refresh tree',
'ou_select' => '— choose an OU —',
'ou_collapse' => 'Collapse the OU tree to the side',
'ou_expand' => 'Expand the OU tree',
// Pagination bar
'pagination_summary' => 'Showing :first - :last of :total',
'pagination_per_page' => 'per page',
'pagination_nav' => 'Table pagination',
'pagination_prev' => 'Previous page',
'pagination_next' => 'Next page',
];
+91
View File
@@ -0,0 +1,91 @@
<?php
return [
'title' => 'Computers',
'heading' => 'Computer management',
'subtitle' => 'Overview of workstations and servers registered in Active Directory',
'ou_tree_title' => 'Computer OU structure',
'os_all' => 'All operating systems',
'toolbar' => [
'set_enabled' => 'Account state',
'move_ou' => 'Move OU',
],
'js' => [
'select_at_least_one' => 'Select at least one computer in the table.',
'selected' => ':count selected computers',
],
'enabled_modal' => [
'title' => 'Computer account state',
'applies_to' => 'Applies to:',
'enable' => 'Enable computer',
'disable' => 'Disable computer',
],
'move_modal' => [
'title' => 'Move to OU',
'applies_to' => 'Applies to:',
'target_ou' => 'Target organisational unit',
'submit' => 'Move',
],
'col' => [
'name' => 'Name',
'os' => 'OS',
'status' => 'Status',
'last_logon' => 'Last logon',
],
'table' => [
'name' => 'Computer name',
'os' => 'Operating system',
'status' => 'Status',
'last_logon' => 'Last logon',
],
'empty' => 'No computers match the criteria.',
'filter_inactive' => 'Dashboard filter: inactive ≥ 90 days',
'actions' => [
'details' => 'Details / LAPS / BitLocker',
'reset_trust' => 'Reset trust account',
'reset_trust_confirm' => 'Reset this computer\'s trust account?',
'enable' => 'Enable computer',
'disable' => 'Disable computer',
],
'modal' => [
'title' => 'Computer',
'title_named' => 'Computer: :name',
'dns' => 'DNS',
'os' => 'OS',
'dn' => 'DN',
'last_logon' => 'Last logon',
'created' => 'Created',
'laps' => 'LAPS',
'bitlocker' => 'BitLocker - recovery keys',
'laps_available' => 'available',
'laps_missing' => 'none / no permission',
'laps_legacy_password' => 'Password (ms-Mcs-AdmPwd):',
'laps_expires' => '(expires :date)',
'laps_windows' => 'Windows LAPS',
'laps_encrypted' => 'Windows LAPS: password is encrypted (msLAPS-EncryptedPassword) - decryption requires DPAPI permissions, out of scope for this panel.',
'laps_none' => 'No LAPS data (not configured or no read permission).',
'bitlocker_none' => 'No recovery keys in AD.',
'bitlocker_guid' => 'GUID',
'bitlocker_key' => 'Recovery key',
'bitlocker_created' => 'Created',
],
'flash' => [
'enabled' => 'Computer :cn has been enabled.',
'disabled' => 'Computer :cn has been disabled.',
'trust_reset' => 'Trust account for computer :cn has been reset.',
'deleted' => 'Computer object :cn has been removed from AD.',
'bulk_enabled' => 'Enabled :count computers.',
'bulk_disabled' => 'Disabled :count computers.',
'bulk_moved' => ':count computers have been moved to the selected OU.',
],
];
+87
View File
@@ -0,0 +1,87 @@
<?php
return [
'title' => 'Configuration',
'heading' => 'Configuration',
'subtitle' => 'Settings for the application itself (not Active Directory data) - stored locally, without a database.',
'tab_general' => 'General',
'tab_attributes' => 'Attributes',
'general' => [
'reset_defaults_heading' => 'Default checkboxes for password reset',
'default_must_change' => 'Tick "User must change password at next logon" by default',
'default_unlock' => 'Tick "Unlock account" by default',
'password_policy_heading' => 'Password policy',
'max_age_label' => 'Maximum password age (days)',
'max_age_help' => 'The panel calculates the password expiry date from this value (default <strong>42</strong>). Accounts set to "password never expires" are skipped. <code>0</code> = do not calculate.',
'generator_heading' => 'Password generator (panel only)',
'generator_length' => 'Generated password length',
'generator_symbols' => 'Special characters',
'generator_help' => 'Used by the "dice" button next to password fields (create / reset). It <strong>does not change</strong> the Active Directory password policy.',
'encryption_heading' => 'Attribute encryption',
'encryption_key' => 'Encryption key',
'encryption_help' => 'Used to decrypt and encrypt attributes whose type is set to "Encrypted text" on the <strong>Attributes</strong> tab (by default <code>msDS-cloudExtensionAttribute1/2</code>). Compatible with Laravel Crypt (AES-CBC + MAC). You can enter any passphrase (key = SHA-256 of the text) or paste a key in <code>base64:…</code> form — if the attributes were already encrypted with <code>APP_KEY</code>, paste that value here. Empty = encryption disabled.',
'columns_note' => 'User list columns (including account status and password expiry) - set their visibility and order on the <strong>Attributes</strong> tab.',
'audit_heading' => 'Audit log',
'require_reason' => 'Require a reason for destructive actions',
'require_reason_help' => 'Applies to disabling an account, deletion, moving to an OU and offboarding. An audit entry is always written regardless of this setting.',
'save' => 'Save configuration',
'system_heading' => 'System settings',
'ldap_from_env' => 'LDAP connection in .env',
'ou_defs_env' => 'OU definitions in .env',
'env_notice' => 'LDAP connection parameters and the sign-in group (<code>AD_LOGIN_GROUP</code>) are changed via the <code>.env</code> file (not through this form).',
],
'attributes' => [
'heading' => 'Active Directory attributes in the panel',
'intro' => 'Defines which AD attributes the panel fetches and shows. <strong>Column</strong> = show the value as an extra column in the user list. <strong>Category</strong> = the tab in the "View / Edit" dialog where the attribute appears. <strong>Type</strong> decides the editor field: <em>text</em>, <em>number</em>, <em>date</em> or <em>encrypted text</em> (value in AD encrypted with the key from the "General" tab). The <em>system</em>, <em>account status</em> and <em>password expiry</em> types are built-in - read-only.',
'add' => 'Add attribute',
'th_order' => 'Order',
'th_key' => 'Key',
'th_label' => 'Label',
'th_ldap' => 'AD attribute',
'th_category' => 'Category',
'th_column' => 'Column',
'th_type' => 'Type',
'move_up' => 'Up',
'move_down' => 'Down',
'badge_computed' => 'computed',
'badge_system' => 'system',
'badge_fixed' => 'built-in',
'optgroup_editable' => 'Editable',
'optgroup_non_editable' => 'Non-editable',
'remove' => 'Remove attribute',
'detail_toggle' => 'Validation rules',
'detail_regex' => 'Pattern (regex with delimiters)',
'detail_regex_placeholder' => '/^[A-Z]{2}\d{4}$/',
'detail_required' => 'Required field',
'detail_help' => 'Help text below the field',
'footer_help' => 'You pick the attribute <strong>type</strong> and the panel chooses the field and validation. <strong>sAMAccountName</strong>, <strong>Path (DN)</strong> (<em>system</em> type) and the computed columns (<em>account status</em>, <em>password expiry</em>) are "built-in" - their type, key and AD attribute cannot be changed, and they cannot be removed. <strong>Account expiry</strong> (accountExpires) has a fixed <em>date</em> type but can be assigned a category (a date field appears in the "View / Edit" dialog; empty = account never expires). All other entries are fully configurable.',
'save' => 'Save attributes',
'tpl_key_placeholder' => 'e.g. myAttribute',
'tpl_label_placeholder' => 'Label in the panel',
'tpl_ldap_placeholder' => 'e.g. extensionAttribute5',
],
'flash' => [
'saved' => 'Panel configuration has been saved.',
'attributes_saved' => 'Attribute configuration has been saved.',
],
'validation' => [
'key_regex' => 'The attribute key may contain only letters, digits, hyphen and underscore.',
'ldap_regex' => 'The AD attribute name may contain only letters, digits and hyphen.',
'regex_invalid' => 'The validation pattern is not a valid regular expression (remember the delimiters, e.g. /.../).',
],
];
+67
View File
@@ -0,0 +1,67 @@
<?php
return [
'title' => 'Dashboard',
'heading' => 'Dashboard',
'subtitle' => 'Domain health summary (cached data, refreshed :time).',
'tiles' => [
'users_total' => 'Users',
'users_disabled' => 'Disabled accounts',
'users_locked' => 'Locked-out accounts',
'pwd_never_expires' => 'Password never expires',
'pwd_expiring_7d' => 'Passwords expiring in ≤ 7 days',
'pwd_expired' => 'Passwords already expired',
'inactive_90d' => 'Inactive ≥ 90 days',
'account_expired' => 'Accounts past expiry date',
'users_no_manager' => 'Accounts without a manager',
'computers_total' => 'Computers',
'computers_inactive_90d' => 'Computers inactive ≥ 90 days',
'groups_total' => 'Groups',
],
'reports' => [
'expiring' => 'Passwords expiring within 7 days',
'expiring_date' => 'Expires',
'inactive' => 'Inactive accounts (last logon ≥ 90 days ago)',
'inactive_date' => 'Last logon',
'recent' => 'New accounts (last 30 days)',
'recent_date' => 'Created',
'pwd_never' => 'Password never expires',
'locked' => 'Locked-out accounts',
'locked_since' => 'Since',
'no_manager' => 'Accounts without a manager',
'computers_inactive' => 'Computers inactive (≥ 90 days)',
'computers_inactive_date' => 'Last logon',
'unlock' => 'Unlock',
'reset' => 'Reset',
],
'tile_hint' => 'Click to open the filtered list',
'stale_hint' => 'Data older than one recalculation cycle — check that background recalculation is working.',
'empty' => 'Could not read data from Active Directory (no results). Check the LDAP connection and OU settings, then recalculate.',
'pwd_age' => [
'heading' => 'Password age (days since last change)',
'lt30' => 'under 30 days',
'd30_60' => '30–60 days',
'd60_90' => '60–90 days',
'gt90' => 'over 90 days',
'unknown' => 'no data / never',
],
'prefs' => [
'button' => 'Choose what to show on the dashboard',
'title' => 'Dashboard preferences',
'sections' => 'Sections',
'section_tiles' => 'Summary tiles',
'section_pwd_age' => 'Password age (histogram)',
'reports_heading' => 'Report cards',
'reports_hint' => 'Tick the cards to show; use the arrows to reorder.',
'save' => 'Save preferences',
],
'user' => 'User',
'computer' => 'Computer',
'footnote' => 'Password expiry is calculated from the panel policy (:days days). "Inactive" excludes disabled accounts.',
];
+65
View File
@@ -0,0 +1,65 @@
<?php
return [
'title' => 'GPO policies',
'heading' => 'Group Policy Objects (GPO)',
'subtitle' => 'Overview of the Group Policy objects defined in the domain',
'read_only_notice' => 'For now this module only reads the list of GPOs and the OUs they are linked to (the :attr attribute). Editing policy contents (registry settings, administrative templates in SYSVOL) will be added in a later stage.',
'search_placeholder' => 'Search policies (name, GUID, OU)...',
'col' => [
'name' => 'Name',
'version' => 'Version',
'config' => 'Configuration',
'linked' => 'Linked OUs',
],
'table' => [
'name' => 'Policy name',
'version' => 'Version',
'config' => 'Configuration',
'linked' => 'Linked OUs',
'changed' => 'Last change',
],
'user' => 'User',
'computer' => 'Computer',
'config_disabled' => 'Disabled',
'no_links' => 'No links',
'details' => 'GPO details',
'empty' => 'No GPO policies defined, or no access to the CN=Policies,CN=System container.',
'rsop' => [
'heading' => 'Which GPOs apply (RSoP-lite)',
'hint' => 'Enter an OU distinguished name - the panel walks from the domain down, honouring blocked inheritance and enforced links.',
'ou_dn' => 'OU DN',
'ou_placeholder' => 'OU=Swinoujscie,OU=oddzial,DC=...',
'need_ou' => 'Enter an OU DN.',
'col_index' => '#',
'col_gpo' => 'GPO',
'col_from' => 'From level',
'blocked_at' => 'Inheritance blocked at: :ou',
'none_effective' => 'No effective GPOs.',
'order_note' => 'Order = order of application; lower entries have higher priority, "enforced" links win over the rest.',
],
'modal' => [
'title' => 'GPO',
'title_named' => 'GPO: :name',
'guid' => 'GUID',
'version_user_computer' => 'Version (User / Computer)',
'config' => 'Configuration',
'cse' => 'CSE extensions',
'cse_value' => 'Computer: :machine, User: :user',
'wmi_filter' => 'WMI filter',
'sysvol' => 'SYSVOL',
'created_changed' => 'Created / changed',
'links' => 'Links',
'links_none' => 'none',
'enforced' => 'enforced',
'link_disabled' => 'link disabled',
],
'domain_root' => 'Domain (Root)',
'not_found' => 'GPO not found.',
];
+66
View File
@@ -0,0 +1,66 @@
<?php
return [
'title' => 'Groups',
'heading' => 'AD group management',
'subtitle' => 'Security and distribution groups in Active Directory',
'add' => 'Add group',
'ou_tree_title' => 'Group OU structure',
'filter' => [
'all_types' => 'All types',
'all_scopes' => 'All scopes',
],
'col' => [
'name' => 'Name',
'type' => 'Type',
'members' => 'Members',
'description' => 'Description',
],
'table' => [
'name' => 'Group name',
'type_scope' => 'Type and scope',
'members' => 'Members',
'description' => 'Description',
'mail' => 'Group e-mail',
],
'members_count' => 'ppl.',
'empty' => 'No groups match the criteria.',
'actions' => [
'members_edit' => 'Members / edit',
'delete' => 'Delete group',
'delete_confirm' => 'Delete group :cn?',
],
'add_modal' => [
'title' => 'Add AD group',
'cn' => 'Group name (CN)',
'description' => 'Description',
'ou' => 'Organisational unit (OU)',
'type' => 'Type',
'scope' => 'Scope',
'submit' => 'Create group',
],
'view_modal' => [
'title' => 'Group',
'title_named' => 'Group: :name',
'description' => 'Description',
'mail' => 'Group e-mail',
'save_desc_mail' => 'Save description / e-mail',
'members_label' => 'Group members:',
'add_member_placeholder' => 'sAMAccountName to add',
],
'flash' => [
'created' => 'Group :cn has been created.',
'updated' => 'Group has been updated.',
'member_added' => 'Member added to the group.',
'member_removed' => 'Member removed from the group.',
'deleted' => 'Group :cn has been deleted.',
],
];
+37
View File
@@ -0,0 +1,37 @@
<?php
return [
'brand' => 'ADconsole',
'domain' => 'Domain:',
'search_placeholder' => 'Search AD (login, e-mail)...',
'theme_toggle' => 'Toggle light / dark theme',
'language' => 'Language',
'admin_panel' => 'Administration panel',
'settings' => 'Settings',
'about' => 'About',
'logout' => 'Sign out',
'dashboard' => 'Dashboard',
'users' => 'Users',
'computers' => 'Computers',
'groups' => 'Groups',
'structure' => 'Org chart',
'gpo' => 'GPO policies',
'audit' => 'Audit log',
'config' => 'Configuration',
'footer' => [
'tagline' => 'Active Directory administration panel',
'author' => 'Author: :name',
'rights' => '© :year :name',
'about' => 'About',
'legal' => 'Legal',
'source' => 'Source code',
],
'shortcuts' => [
'title' => 'Keyboard shortcuts',
'search' => 'Jump to the search box',
'help' => 'This dialog',
],
];
+21
View File
@@ -0,0 +1,21 @@
<?php
return [
'title' => 'Org chart',
'heading' => 'Organisational structure',
'subtitle' => 'Only active accounts within the hierarchy (cached data, :time).',
'expand_all' => 'Expand all',
'collapse_all' => 'Collapse',
'fit' => 'Fit to screen',
'compact' => 'Compact mode',
'export_png' => 'Export PNG',
'export_pdf' => 'Export PDF',
'search_placeholder' => 'Jump to a person...',
'empty' => 'No data - no active account has the :field field set, nor any direct reports.',
'hint' => 'Drag to pan; scroll to zoom; click a node to collapse/expand its branch.',
'chart_load_error' => 'Failed to load the chart library (d3-org-chart).',
'top_level' => 'Top level',
];
+70
View File
@@ -0,0 +1,70 @@
<?php
return [
'about' => [
'title' => 'About',
'heading' => 'About ADconsole',
'lead' => 'An Active Directory administration panel built on Laravel and LdapRecord.',
'nav_project' => 'About the project',
'nav_author' => 'Author',
'nav_legal' => 'Legal',
],
'project' => [
'heading' => 'About the project',
'p1' => 'ADconsole is a web application for managing an Active Directory domain: users, computers, groups and read-only Group Policy. It is built on the Laravel framework, and the LdapRecord library is the sole directory access layer.',
'p2' => 'The application keeps no database of AD objects - every operation runs directly against a domain controller. The database backs the application layer only (sessions, cache, queues and the panel configuration).',
'p3' => 'Sign-in uses a domain account, and access to the panel is restricted to members of a designated Active Directory group (the AD_LOGIN_GROUP setting). All administrative actions run under the service account configured in the .env file.',
'modules_heading' => 'Modules',
'modules' => [
'users' => 'Users - list with an OU tree, search, creation, view and edit, password reset (single and bulk), moving between OUs, password expiry policy, disabling and unlocking accounts.',
'computers' => 'Computers - list with an OU tree, operating-system filter, LAPS and BitLocker key view, trust-account reset, enabling and disabling the object, deletion.',
'groups' => 'Groups - list with type and scope filters, group creation, description and e-mail editing, member management, deletion.',
'gpo' => 'GPO policies - reading the list of Group Policy objects and linked OUs, a simplified RSoP analysis. Read-only.',
'config' => 'Panel configuration - application settings and the definitions of AD attributes shown in lists and in the edit dialog.',
],
],
'author' => [
'heading' => 'Author',
'name' => 'Kacper Żbikowski',
'p1' => 'ADconsole is authored and maintained by Kacper Żbikowski.',
'copyright' => '© :year ADconsole. All rights reserved except as granted by the licence below.',
],
'legal' => [
'heading' => 'Legal',
'license_heading' => 'Licence and warranty disclaimer',
'license_summary' => 'ADconsole is released under the MIT licence. The software is provided "as is", without warranty of any kind. You use it at your own risk, and the author is not liable for any damages arising from its use.',
'license_full_heading' => 'Full text of the MIT licence',
'mit' => 'MIT License
Copyright (c) :year :holder
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.',
'trademarks_heading' => 'Trademarks',
'trademarks' => 'Active Directory, Windows, Windows Server, Group Policy and the other Microsoft product names mentioned are trademarks or registered trademarks of Microsoft Corporation. ADconsole is an independent tool and is not affiliated with, endorsed, sponsored or authorised by Microsoft Corporation in any way.',
'privacy_heading' => 'Data protection (GDPR)',
'privacy' => 'ADconsole processes employees\' personal data retrieved from Active Directory (including name, login, e-mail address, phone number, job title, department and photo). The data controller within the meaning of the GDPR is the organisation that deploys and operates the panel - it is responsible for the legal basis of processing, the set of exposed attributes, retention periods and handling data-subject requests. Access to the panel should be limited to authorised administrators only, and the connection to the domain controller should be secured with LDAPS or STARTTLS. The software\'s author is neither a controller nor a processor of this data.',
],
];
+252
View File
@@ -0,0 +1,252 @@
<?php
return [
'title' => 'Users',
'heading' => 'User management',
'subtitle' => 'Overview of user accounts registered in Active Directory',
'ou_tree_title' => 'OU structure',
'ou_refresh_counts' => 'Refresh tree (recalculate counters)',
'toolbar' => [
'add' => 'Add user',
'move_ou' => 'Move OU',
'reset_password' => 'Reset password',
'password_expiry' => 'Password expiry',
'set_enabled' => 'Account state',
'group' => 'Group',
'force_password_change' => 'Force password change',
'unlock' => 'Unlock',
'bulk_attributes' => 'Edit attributes',
'offboard' => 'Offboarding',
'export' => 'CSV export',
],
'status_filter' => [
'all' => 'Account status: all',
],
'saved_filters' => [
'button' => 'Filters',
'save_current' => 'Save current view',
'prompt_name' => 'Name for the saved filter:',
'empty' => 'No saved filters.',
'delete' => 'Delete',
],
// Labels for filters coming from dashboard tiles (?filter=...).
'dashboard_filter' => [
'active' => 'Dashboard filter:',
'clear' => 'Clear filter',
'locked' => 'Locked-out accounts',
'pwd_never' => 'Password never expires',
'pwd_expiring' => 'Passwords expiring in ≤ 7 days',
'pwd_expired' => 'Passwords already expired',
'inactive' => 'Inactive ≥ 90 days',
'account_expired' => 'Accounts past expiry date',
'no_manager' => 'Accounts without a manager',
],
'table' => [
'empty' => 'No users match the criteria.',
'password_never_expires' => 'Never expires',
'account_never_expires' => 'Never expires',
],
'row_actions' => [
'view_edit' => 'View / Edit',
'reset_password' => 'Reset password',
'move_ou' => 'Move to OU',
'password_expiry' => 'Password expiry',
'enable' => 'Unlock account',
'disable' => 'Disable account',
'force_password_change' => 'Force password change',
'unlock' => 'Unlock (clear lockout)',
'offboard' => 'Offboarding',
],
'js' => [
'select_at_least_one' => 'Select at least one user in the table.',
'selected_users' => ':count selected users',
'profile_title' => 'User profile: :name',
'no_groups' => 'No groups.',
'managers_chain' => 'Managers: ',
'no_manager' => 'No manager assigned.',
'no_reports' => 'No direct reports.',
'structure_load_error' => 'Failed to load the structure.',
'remove_from_group_confirm' => 'Remove from this group?',
'template_loading' => 'Loading template...',
'template_loaded' => 'Template: :name — OU and organisational data pre-filled, groups: :count.',
'template_load_error' => 'Failed to load the template.',
'photo_saving' => 'Saving...',
'photo_saved' => 'Saved. Refreshing...',
'photo_save_error' => 'Failed to save the photo.',
'photo_choose_file' => 'Choose a file.',
'connection_error' => 'Connection error.',
],
'pwd' => [
'toggle' => 'Show / hide',
'generate' => 'Generate password',
'copy' => 'Copy',
],
'add_modal' => [
'title' => 'Add a new AD user',
'template_label' => 'Create like... (template - optional)',
'template_placeholder' => 'Type the name / login of the person to copy from...',
'copy_attributes' => 'Copy organisational data (department, company, title, address, manager)',
'copy_groups' => 'Copy group membership',
'given_name' => 'First name',
'given_name_placeholder' => 'e.g. Piotr',
'surname' => 'Last name',
'surname_placeholder' => 'e.g. Zieliński',
'samaccountname' => 'Logon name (sAMAccountName)',
'samaccountname_placeholder' => 'pzielinski',
'email' => 'E-mail',
'email_placeholder' => 'p.zielinski@example.com',
'ou' => 'Organisational unit (OU)',
'initial_password' => 'Initial password',
'password_hint' => 'Min. 8 characters. Generator in the panel configuration.',
'must_change_password' => 'User must change password at next logon',
'account_enabled' => 'Account enabled immediately',
'submit' => 'Create user',
'groups_section' => 'Groups',
'groups_placeholder' => 'Type a group name and pick from the list...',
'summary_title' => 'Account :name created:',
'summary_enabled' => 'account enabled',
'summary_disabled' => 'account disabled',
'summary_must_change' => 'password change at first logon',
'summary_groups' => 'added to groups: :list',
'summary_groups_failed' => 'could not add to groups: :list',
],
'reset_modal' => [
'title' => 'Reset password',
'applies_to' => 'Applies to:',
'new_password' => 'New password',
'must_change_password' => 'User must change password at next logon',
'unlock_account' => 'Unlock account (clear the logon lockout)',
'submit' => 'Reset password',
],
'move_modal' => [
'title' => 'Move to OU',
'applies_to' => 'Applies to:',
'target_ou' => 'Target organisational unit',
'submit' => 'Move',
],
'expiry_modal' => [
'title' => 'Password and account expiry',
'applies_to' => 'Applies to:',
'password_section' => 'Password',
'password_never' => 'Password never expires',
'account_section' => 'Account (accountExpires)',
'account_never' => 'Account never expires (clear the date)',
'account_on_date' => 'or the account expires on (single-user action only):',
],
'enabled_modal' => [
'title' => 'Account state',
'applies_to' => 'Applies to:',
'enable' => 'Enable account (and clear the logon lockout)',
'disable' => 'Disable account',
],
'group_modal' => [
'title' => 'Group membership',
'applies_to' => 'Applies to:',
'group' => 'Group',
'mode_add' => 'Add to group',
'mode_remove' => 'Remove from group',
],
'force_pwd_modal' => [
'title' => 'Force password change',
'applies_to' => 'Applies to:',
'hint' => 'At next logon the user will have to set a new password. The current password is not changed.',
'submit' => 'Force change',
],
'unlock_modal' => [
'title' => 'Unlock account',
'applies_to' => 'Applies to:',
'hint' => 'Clears the logon lockout (lockoutTime). Does not change the account enabled state or the password.',
'submit' => 'Unlock',
],
'bulk_attr_modal' => [
'title' => 'Bulk attribute edit',
'applies_to' => 'Applies to:',
'hint' => 'Tick the fields you want to change. Unticked fields are left as they are. Ticked and empty clears the attribute.',
'value_placeholder' => 'New value',
'submit' => 'Apply',
],
'offboard_modal' => [
'title' => 'Account offboarding',
'applies_to' => 'Applies to:',
'hint' => 'Tick the steps to run. Order: attribute changes → group removal → OU move (last, changes the DN).',
'step_disable' => 'Disable the account and set a random password',
'step_groups' => 'Remove from all groups',
'step_groups_keep_primary' => 'Keep the primary group',
'step_move' => 'Move to an organisational unit',
'step_expire' => 'Set the account expiry date and append to the description',
'expire_hint' => 'Appends to the "description" field: "Offboarded YYYY-MM-DD by <login>".',
'submit' => 'Run offboarding',
],
'view_modal' => [
'title' => 'User profile',
'tab_structure' => 'Structure',
'tab_groups' => 'Groups (membership)',
'tab_photo' => 'Photo',
'manager_placeholder' => 'Type the manager\'s name or login...',
'manager_dn_placeholder' => 'Manager DN (filled in after selection)',
'manager_clear' => 'Clear manager',
'account_expiry_hint' => 'Empty = account never expires.',
'structure_intro' => 'Structure built from the :from → :to relationship.',
'structure_full_link' => 'Full organisational structure',
'group_add_label' => 'Add to group:',
'group_add_placeholder' => 'Type a group name...',
'group_member_of' => 'Member of the following Active Directory groups:',
'photo_alt' => 'No photo',
'photo_upload_label' => 'Upload a photo (JPG/PNG, square, up to 2&nbsp;MB - it will be scaled to 96×96 and stored in <code>thumbnailPhoto</code>)',
'photo_upload' => 'Upload',
'photo_remove' => 'Remove photo',
],
'flash' => [
'created' => 'User :name has been created:suffix.',
'created_suffix' => ' (template: :template:groups)',
'created_suffix_groups' => ', groups copied: :count',
'updated' => 'User data has been saved.',
'password_reset' => 'Password for user :name has been reset.',
'expiry_updated' => 'Password / account expiry policy has been updated.',
'moved' => 'User has been moved to the selected OU.',
'unlocked' => 'The account has been unlocked.',
'locked' => 'The account has been disabled.',
'deleted' => 'User has been deleted.',
'photo_removed' => 'The photo has been removed.',
'photo_saved' => 'The photo has been saved.',
'bulk_password_reset' => 'Password has been reset for :count users.',
'bulk_moved' => ':count users have been moved to the selected OU.',
'bulk_expiry_updated' => 'Password policy has been updated for :count users.',
'group_added' => 'Added to group :group.',
'group_removed' => 'Removed from the group.',
'bulk_enabled' => 'Enabled :count accounts.',
'bulk_disabled' => 'Disabled :count accounts.',
'bulk_group_added' => 'Added :count users to group :group.',
'bulk_group_removed' => 'Removed :count users from group :group.',
'force_pwd_change' => 'Forced a password change for :name at next logon.',
'bulk_force_pwd_change' => 'Forced a password change for :count users at next logon.',
'unlocked_one' => 'Account :name has been unlocked.',
'bulk_unlocked' => 'Unlocked :count accounts.',
'bulk_attributes' => 'Updated :fields fields for :count accounts.',
'offboarded' => 'Offboarding of account :name completed.',
'bulk_offboarded' => 'Offboarding completed for :count accounts.',
],
];
+221
View File
@@ -0,0 +1,221 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => 'The :attribute field must be accepted.',
'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
'active_url' => 'The :attribute field must be a valid URL.',
'after' => 'The :attribute field must be a date after :date.',
'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
'alpha' => 'The :attribute field must only contain letters.',
'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
'alpha_num' => 'The :attribute field must only contain letters and numbers.',
'array' => 'The :attribute field must be an array.',
'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
'before' => 'The :attribute field must be a date before :date.',
'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
'between' => [
'array' => 'The :attribute field must have between :min and :max items.',
'file' => 'The :attribute field must be between :min and :max kilobytes.',
'numeric' => 'The :attribute field must be between :min and :max.',
'string' => 'The :attribute field must be between :min and :max characters.',
],
'boolean' => 'The :attribute field must be true or false.',
'can' => 'The :attribute field contains an unauthorized value.',
'confirmed' => 'The :attribute field confirmation does not match.',
'contains' => 'The :attribute field is missing a required value.',
'current_password' => 'The password is incorrect.',
'date' => 'The :attribute field must be a valid date.',
'date_equals' => 'The :attribute field must be a date equal to :date.',
'date_format' => 'The :attribute field must match the format :format.',
'decimal' => 'The :attribute field must have :decimal decimal places.',
'declined' => 'The :attribute field must be declined.',
'declined_if' => 'The :attribute field must be declined when :other is :value.',
'different' => 'The :attribute field and :other must be different.',
'digits' => 'The :attribute field must be :digits digits.',
'digits_between' => 'The :attribute field must be between :min and :max digits.',
'dimensions' => 'The :attribute field has invalid image dimensions.',
'distinct' => 'The :attribute field has a duplicate value.',
'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',
'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',
'email' => 'The :attribute field must be a valid email address.',
'ends_with' => 'The :attribute field must end with one of the following: :values.',
'enum' => 'The selected :attribute is invalid.',
'exists' => 'The selected :attribute is invalid.',
'extensions' => 'The :attribute field must have one of the following extensions: :values.',
'file' => 'The :attribute field must be a file.',
'filled' => 'The :attribute field must have a value.',
'gt' => [
'array' => 'The :attribute field must have more than :value items.',
'file' => 'The :attribute field must be greater than :value kilobytes.',
'numeric' => 'The :attribute field must be greater than :value.',
'string' => 'The :attribute field must be greater than :value characters.',
],
'gte' => [
'array' => 'The :attribute field must have :value items or more.',
'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',
'numeric' => 'The :attribute field must be greater than or equal to :value.',
'string' => 'The :attribute field must be greater than or equal to :value characters.',
],
'hex_color' => 'The :attribute field must be a valid hexadecimal color.',
'image' => 'The :attribute field must be an image.',
'in' => 'The selected :attribute is invalid.',
'in_array' => 'The :attribute field must exist in :other.',
'integer' => 'The :attribute field must be an integer.',
'ip' => 'The :attribute field must be a valid IP address.',
'ipv4' => 'The :attribute field must be a valid IPv4 address.',
'ipv6' => 'The :attribute field must be a valid IPv6 address.',
'json' => 'The :attribute field must be a valid JSON string.',
'list' => 'The :attribute field must be a list.',
'lowercase' => 'The :attribute field must be lowercase.',
'lt' => [
'array' => 'The :attribute field must have less than :value items.',
'file' => 'The :attribute field must be less than :value kilobytes.',
'numeric' => 'The :attribute field must be less than :value.',
'string' => 'The :attribute field must be less than :value characters.',
],
'lte' => [
'array' => 'The :attribute field must not have more than :value items.',
'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
'numeric' => 'The :attribute field must be less than or equal to :value.',
'string' => 'The :attribute field must be less than or equal to :value characters.',
],
'mac_address' => 'The :attribute field must be a valid MAC address.',
'max' => [
'array' => 'The :attribute field must not have more than :max items.',
'file' => 'The :attribute field must not be greater than :max kilobytes.',
'numeric' => 'The :attribute field must not be greater than :max.',
'string' => 'The :attribute field must not be greater than :max characters.',
],
'max_digits' => 'The :attribute field must not have more than :max digits.',
'mimes' => 'The :attribute field must be a file of type: :values.',
'mimetypes' => 'The :attribute field must be a file of type: :values.',
'min' => [
'array' => 'The :attribute field must have at least :min items.',
'file' => 'The :attribute field must be at least :min kilobytes.',
'numeric' => 'The :attribute field must be at least :min.',
'string' => 'The :attribute field must be at least :min characters.',
],
'min_digits' => 'The :attribute field must have at least :min digits.',
'missing' => 'The :attribute field must be missing.',
'missing_if' => 'The :attribute field must be missing when :other is :value.',
'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
'missing_with' => 'The :attribute field must be missing when :values is present.',
'missing_with_all' => 'The :attribute field must be missing when :values are present.',
'multiple_of' => 'The :attribute field must be a multiple of :value.',
'not_in' => 'The selected :attribute is invalid.',
'not_regex' => 'The :attribute field format is invalid.',
'numeric' => 'The :attribute field must be a number.',
'password' => [
'letters' => 'The :attribute field must contain at least one letter.',
'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',
'numbers' => 'The :attribute field must contain at least one number.',
'symbols' => 'The :attribute field must contain at least one symbol.',
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
],
'present' => 'The :attribute field must be present.',
'present_if' => 'The :attribute field must be present when :other is :value.',
'present_unless' => 'The :attribute field must be present unless :other is :value.',
'present_with' => 'The :attribute field must be present when :values is present.',
'present_with_all' => 'The :attribute field must be present when :values are present.',
'prohibited' => 'The :attribute field is prohibited.',
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.',
'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.',
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
'prohibits' => 'The :attribute field prohibits :other from being present.',
'regex' => 'The :attribute field format is invalid.',
'required' => 'The :attribute field is required.',
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
'required_if' => 'The :attribute field is required when :other is :value.',
'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
'required_if_declined' => 'The :attribute field is required when :other is declined.',
'required_unless' => 'The :attribute field is required unless :other is in :values.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_with_all' => 'The :attribute field is required when :values are present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute field must match :other.',
'size' => [
'array' => 'The :attribute field must contain :size items.',
'file' => 'The :attribute field must be :size kilobytes.',
'numeric' => 'The :attribute field must be :size.',
'string' => 'The :attribute field must be :size characters.',
],
'starts_with' => 'The :attribute field must start with one of the following: :values.',
'string' => 'The :attribute field must be a string.',
'timezone' => 'The :attribute field must be a valid timezone.',
'unique' => 'The :attribute has already been taken.',
'uploaded' => 'The :attribute failed to upload.',
'uppercase' => 'The :attribute field must be uppercase.',
'url' => 'The :attribute field must be a valid URL.',
'ulid' => 'The :attribute field must be a valid ULID.',
'uuid' => 'The :attribute field must be a valid UUID.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap our attribute placeholder
| with something more reader friendly such as "E-Mail Address" instead
| of "email". This simply helps us make our message more expressive.
|
*/
'attributes' => [
'login' => 'login',
'password' => 'password',
'given_name' => 'first name',
'surname' => 'last name',
'samaccountname' => 'logon name',
'samaccountnames' => 'selected users',
'upn_prefix' => 'UPN prefix',
'mail' => 'e-mail address',
'ou' => 'organisational unit',
'target_ou' => 'target organisational unit',
'template_sam' => 'template account',
'account_expire_on' => 'account expiry date',
'cn' => 'group name',
'description' => 'description',
'group_type' => 'group type',
'group_scope' => 'group scope',
'group_cn' => 'group',
'group_dn' => 'group',
'member_dn' => 'member',
'photo' => 'photo',
'password_policy.max_age_days' => 'maximum password age',
'password_policy.generator_length' => 'generated password length',
'encryption.key' => 'encryption key',
'attributes' => 'attributes',
],
];
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more