Users and permissions
This page covers the package’s own end-user login system: the darvis-nuki auth guard, the NukiUser model, main/sub-user hierarchy, per-smartlock permissions, the validity window and the weekday bitmask. It is completely independent from how the package authenticates against the NUKI Web API — for that, see NUKI API authentication.
Everything on this page is opt-in. Without NUKI_AUTH_USERS_ENABLED=true the guard and the routes are not registered. The tables are created by php artisan migrate either way and stay empty.
A guard is Laravel’s name for one way of signing in, with its own user model and session; see the Laravel documentation on guards. The package’s guard is separate from your application’s users: a NukiUser is not an App\Models\User.
From nothing to a signed in main user
- Set
NUKI_AUTH_USERS_ENABLED=truein.env(the wordtrue, not1) and runphp artisan config:clearandphp artisan migrate. - Make sure your application can send mail. Signing in needs two mails: a confirmation link the first time, and a login code every time.
- Create the user:
php artisan nuki:user-create --email=admin@example.com --name=Admin. The command asks for the password. Add--account=<account_key>for every existing account the user works in. - Open
/nuki/loginand sign in. The first attempt sends a confirmation link and shows “Confirm your email address”; open the link, sign in again and enter the emailed code. - Create the NUKI accounts on
/nuki/accounts; the user who creates one is attached to it. For accounts that already exist, see Attach a main user to an account.
The model in one picture
┌── NukiUser (main, parent_id = NULL) ──────────────────────────────────────┐
│ accessible accounts: the accounts attached to this user │
│ accessible smartlocks: WILDCARD (every smartlock the account exposes) │
│ │
│ ├── NukiUser (sub, parent_id = main.id) │
│ │ accessible accounts: own ∪ parent's accounts │
│ │ accessible smartlocks: ONLY pivot rows on nuki_user_smartlock │
│ │ (with permissions, window, weekday bitmask) │
│ └── ... │
└────────────────────────────────────────────────────────────────────────────┘
- Main users are wildcard: they see every smartlock in every account they belong to.
- Sub users inherit account access from their parent, but never inherit smartlock access — every smartlock they can touch is explicitly listed on
nuki_user_smartlockwithcan_lock/can_unlock/can_view_logs/can_manage_authsflags.
What switching it on does
Enable in .env:
NUKI_AUTH_USERS_ENABLED=true
NUKI_AUTH_USERS_MAIL_FROM_ADDRESS=noreply@yourapp.example
NUKI_AUTH_USERS_MAIL_FROM_NAME="Your App"
When this flag is on, NukiServiceProvider:
-
Calls AuthConfigRegistrar::register(), which merges into
auth.guards/auth.providersat runtime:'guards' => [ 'darvis-nuki' => ['driver' => 'session', 'provider' => 'darvis-nuki-users'], ], 'providers' => [ 'darvis-nuki-users' => ['driver' => 'eloquent', 'model' => \Darvis\Nuki\Models\NukiUser::class], ],The registrar is idempotent: if you have already defined the guard or provider in your own
config/auth.php, your definition wins. This matters when another package’s service provider readsauth.guardsbeforeNukiServiceProvider::register()runs — in that case, define them explicitly inconfig/auth.phpand the runtime merge becomes a no-op. -
Loads routes/auth.php — see Auth routes.
-
Puts every UI route from routes/web.php behind the
darvis-nukiguard. A visitor who is not signed in is redirected to/nuki/login; see Where a guest is sent for versions before 1.3.0. -
Registers the auth Livewire components (
nuki.auth.login,nuki.auth.otp,nuki.auth.register,nuki.auth.forgot-password,nuki.auth.reset-password,nuki.auth.verify-email,nuki.profile,nuki.sub-users-index,nuki.sub-user-show).
Constants for hard-coded use:
use Darvis\Nuki\Auth\Users\AuthConfigRegistrar;
AuthConfigRegistrar::GUARD; // 'darvis-nuki'
AuthConfigRegistrar::PROVIDER; // 'darvis-nuki-users'
The NukiUser model
Source: src/Models/NukiUser.php. Table: nuki_users.
Columns
| Column | Type | Notes |
|---|---|---|
id | bigint PK | |
parent_id | bigint, nullable, FK → nuki_users.id | NULL = main user, otherwise sub. |
name | string | Display name. |
email | string, unique | Login identifier. |
email_verified_at | timestamp, nullable | Set when the user opens the confirmation link. While auth_users.email_verification.enabled is true (the default), a user without it cannot sign in. Not mass assignable. |
password | string | Hashed via the hashed cast. |
remember_token | rememberToken | Standard Laravel. |
two_factor_enabled | bool, default true | Stored, and not read at login: while auth_users.otp.enabled is true every user gets a login code, whatever this column says. |
is_active | bool, default true | When false, the user cannot log in (the LoginPage filters on this). |
last_login_at | timestamp, nullable | Updated on successful login. |
locale | string(5), nullable | Preferred UI language, set on the profile page. See Localization for the fallbacks. |
timestamps |
Relations
parent(): BelongsTo— the main user this sub belongs to, ornull.subUsers(): HasMany— all subs of this main user.accounts(): BelongsToMany— vianuki_user_account, withrolepivot ('owner','member', or anything you store there).smartlockAccess(): HasMany—nuki_user_smartlockrows. Only meaningful for sub users.otpCodes(): HasMany— issued login OTPs.
Hierarchy helpers
$user->isMain(); // parent_id === null
$user->isSub(); // parent_id !== null
$user->accessibleAccounts();
// Collection<NukiAccount>
// - For mains: their own active account pivots.
// - For subs: own pivots ∪ parent's active accounts. Deduped by id.
$user->accessibleSmartlockIds(int $nukiAccountId): ?array;
// - Main users → null (wildcard: trust the account).
// - Sub users → array of smartlock_id values where the pivot is active
// AND currently allowed (window + weekday match now).
$user->canAccessSmartlock(int $accountId, int $smartlockId, string $permission): bool;
// - Mains → always true.
// - Subs → look up the pivot, check is_active, isCurrentlyAllowed(),
// hasPermission($permission).
The permission matrix
Source: NukiUserSmartlockAccess. Table: nuki_user_smartlock.
Unique on (nuki_user_id, nuki_account_id, smartlock_id) — a sub user has at most one pivot per (account, lock) combination.
Columns
| Column | Type | Notes |
|---|---|---|
nuki_user_id | FK → nuki_users.id | The sub user this rule belongs to. |
nuki_account_id | FK → nuki_accounts.id | Which NUKI account exposes this lock. |
smartlock_id | unsignedBigInt | NUKI smartlock id from the Web API. Not a foreign key — smartlocks live in NUKI, not in your DB. |
can_lock | bool, default false | |
can_unlock | bool, default false | |
can_view_logs | bool, default false | |
can_manage_auths | bool, default false | Allowed to create/edit/delete keypad codes and other authorizations on this lock. |
allowed_from | timestamp, nullable | Start of the validity window. NULL = no lower bound. |
allowed_until | timestamp, nullable | End of the validity window. NULL = no upper bound. |
allowed_weekdays | tinyint, nullable | Weekday bitmask. NULL or 0 = no weekday restriction. |
is_active | bool, default true | Master switch for this row. |
timestamps |
isCurrentlyAllowed(?CarbonImmutable $now = null): bool
Returns true only when all of:
is_activeis true.allowed_fromis null or in the past.allowed_untilis null or in the future.allowed_weekdaysis null/0 or matches today via WeekdayBitmask::matchesDate().
hasPermission(string $permission): bool
Permission strings: 'lock', 'unlock', 'view_logs', 'manage_auths' (constant: NukiUserSmartlockAccess::PERMISSIONS). The method maps each string to the matching can_* column. Anything outside the four permitted values returns false.
Weekday bitmask
Source: WeekdayBitmask. A bitmask is one number that holds seven yes/no answers, one bit per day. The helper takes Dutch day codes:
| Code | Day | Bit |
|---|---|---|
ma | Monday | 64 |
di | Tuesday | 32 |
wo | Wednesday | 16 |
do | Thursday | 8 |
vr | Friday | 4 |
za | Saturday | 2 |
zo | Sunday | 1 |
use Darvis\Nuki\Support\WeekdayBitmask;
WeekdayBitmask::fromDays(['ma', 'wo', 'vr']); // 64 | 16 | 4 = 84
WeekdayBitmask::toDays(84); // ['ma', 'wo', 'vr']
WeekdayBitmask::matchesDate(84, now()); // true on Mon/Wed/Fri
fromDays() returns null for an empty list, which means “no restriction”. The bundled smartlock page sends the same number to NUKI as allowedWeekDays when it saves a keypad code (see the API reference), so one helper serves both.
Authorizing access in your code
The trait AuthorizesSmartlockAccess is mixed into the bundled Livewire components and is the canonical way to gate access. Use it from your own controllers / components too.
In app/Http/Controllers/MyOwnLockController.php:
namespace App\Http\Controllers;
use Darvis\Nuki\Concerns\AuthorizesSmartlockAccess;
use Darvis\Nuki\Facades\Nuki;
class MyOwnLockController extends Controller
{
use AuthorizesSmartlockAccess;
public function unlock(int $smartlockId)
{
$accountKey = session('nuki.current_account', 'default');
$this->assertCan($accountKey, $smartlockId, 'unlock'); // aborts 403 if not allowed
Nuki::as($accountKey)->smartlocks()->unlock($smartlockId);
}
}
The trait’s surface:
| Method | What it does |
|---|---|
userAccessibleSmartlockIds(string $accountKey): ?array | null = no auth user or a main user (caller trusts the full list); array of ints = explicit allow-list for a sub user, empty when the account key has no nuki_accounts row. Use this to filter Nuki::smartlocks()->all() for sub users. |
userCanAccessSmartlock(string $accountKey, int $smartlockId, string $permission): bool | true when no auth user is active, or the user is a main, or the sub has a matching active pivot row. |
assertCan(string $accountKey, int $smartlockId, string $permission): void | Calls userCanAccessSmartlock; abort(403) on false. |
currentNukiAuthUser(): ?NukiUser | The user behind the darvis-nuki guard, or null when the feature is off. |
Always re-check in the action handler, even if the UI hides the button — the trait does this for SmartlocksIndex, SmartlockShow and others. The UI is a hint; the server is the line of defence.
What the bundled pages enforce for a package user:
SmartlockShowloads the lock only with an active row for it, the activity tab only withview_logsand the authorizations tab (keypad codes included) only withmanage_auths; anything else is a403. ThesmartlockIdandaccountKeyproperties are#[Locked], so the browser cannot point a mounted page at another lock or account.- The account switcher lists the user’s accessible accounts only, and switching to any other key than
defaultor one of those is a403. The same check runs in everynuki-account-changedhandler, because the browser can send that event too. AccountsIndex(API tokens),WebhooksIndexandOAuthConnectare for a main user: a sub user gets a403on the page and on every action, and the navigation hides the links.Dashboardcounts and lists only the locks a sub user has an active row for, and its recent activity only has entries of locks withview_logs.ActivityTimelineonly shows entries of locks withview_logs, only offers those locks in its filter, and answers403for a?lock=filter on any other lock.SubUsersIndexandSubUserShoware for a main user, on every request. The sub user editor only offers the accounts the main user is attached to, and giving access on another account, or changing a row of somebody else’s sub user, is refused.
The emailed login code (OTP)
LoginPage handles the password step. On a valid password it:
- Refuses a user who has not confirmed their email address (while
auth_users.email_verification.enabledistrue): it mails a new confirmation link and shows the notice page. - Checks
auth_users.otp.enabled. When it isfalse, the user is signed in immediately, and that goes for everyone. There is no per user switch:two_factor_enabledand--no-2faare stored and not read here. - Throttles via
LoginThrottle(auth_users.otp.rate_limit.*): at mostmax_per_windowcodes per email address and IP per window. - Generates a code through
NukiUserOtpCode::generate(...). The plain code is mailed; only the hash is stored onnuki_user_otp_codes. - Mails NukiLoginOtpMail in the user’s
locale. - Keeps the pending login in the session and redirects to
/nuki/login/otp. The pending login is valid for 15 minutes.
LoginOtpPage validates the code against the stored hash, checks expires_at, marks consumed_at, and finishes the login on success. Throttles further attempts.
Relevant config: auth_users.otp.enabled, auth_users.otp.expiry_minutes, auth_users.otp.length, auth_users.otp.rate_limit.*. See Configuration reference → auth_users.*.
Storage
nuki_user_otp_codes: code_hash, purpose (default 'login'), expires_at, consumed_at, ip, user_agent. Indexed on (nuki_user_id, consumed_at) and expires_at.
Password reset
NukiPasswordResetService runs the flow:
sendResetLink(string $email)— finds the active user, generates a 64-char token, hashes it intonuki_password_resets, mails NukiPasswordResetMail.findUserForToken(string $email, string $token)— used by the reset page to validate the link before showing the form.reset(string $email, string $token, string $newPassword)— updates the password, refreshes theremember_token, deletes the reset row.
The service deliberately does not use Laravel’s PasswordBroker, which avoids config/auth.passwords merging issues across Laravel 11/12/13. Token lifetime is controlled by auth_users.password_reset.token_lifetime_minutes (default 60).
Table: nuki_password_resets. Primary key is email (one outstanding reset per address).
Creating and managing users
First main user — CLI
php artisan nuki:user-create \
--email=admin@example.com \
--name="Admin" \
--password=secret123
Every option you leave out is asked for. --no-2fa stores two_factor_enabled = false on the user; that column is not read at login, so the user still gets a login code while auth_users.otp.enabled is true.
The new user has no confirmed email address. With the default settings the first sign in mails a confirmation link, and the user can only sign in after opening it.
The command is the way to create a main user. The /nuki/register page does the same for any visitor, which is why it is off by default: a main user may operate every lock. Switch it on with NUKI_AUTH_USERS_REGISTER_ENABLED=true only when every visitor with a mailbox may do that.
Source: NukiUserCreateCommand. Always creates a main user (parent_id = null, is_active = true).
Sub users in the UI
Once the main user logs in, /nuki/sub-users lists their sub users and /nuki/sub-users/{id} is the editor for one of them: a row per smartlock with the account it is on, the four permissions, the period and the weekday grid. The editor offers the accounts the main user is attached to, so attach the main user first. Components: SubUsersIndex, SubUserShow.
A new sub user has no confirmed email address either: the first sign in mails the confirmation link. The “two factor” switch in the sub user form fills two_factor_enabled, which is not read at login.
Sub users in code
use Darvis\Nuki\Models\NukiUser;
use Darvis\Nuki\Models\NukiAccount;
use Darvis\Nuki\Models\NukiUserSmartlockAccess;
use Darvis\Nuki\Support\WeekdayBitmask;
$main = NukiUser::firstWhere('email', 'admin@example.com');
$sub = NukiUser::create([
'parent_id' => $main->id,
'name' => 'Cleaning crew',
'email' => 'crew@example.com',
'password' => 'temp-password-they-will-reset',
'two_factor_enabled' => true,
'is_active' => true,
]);
// Subs inherit account access from the parent, but you can still grant
// extra direct accounts via the pivot if you want.
$account = NukiAccount::firstWhere('account_key', 'tenant-42');
NukiUserSmartlockAccess::create([
'nuki_user_id' => $sub->id,
'nuki_account_id' => $account->id,
'smartlock_id' => 17_123_456_789,
'can_lock' => true,
'can_unlock' => true,
'can_view_logs' => true,
'can_manage_auths' => false,
'allowed_from' => now()->startOfMonth(),
'allowed_until' => now()->endOfMonth(),
'allowed_weekdays' => WeekdayBitmask::fromDays(['ma', 'wo', 'vr']),
'is_active' => true,
]);
That sub user will now see exactly one lock in the bundled UI, can lock and unlock it but cannot manage keypad codes, and only between the start and end of this month, and only on Mondays, Wednesdays and Fridays.
Attach a main user to an account
The table nuki_user_account links a user to a NUKI account, with a free-form role column (default member; the package does not read the role). Source: migration.
A package user only sees, and can only switch to, the accounts they are attached to (plus default). Since version 1.3.0 the package attaches in two places, both with the role owner (NukiAccount::ROLE_OWNER):
- A main user who creates an account on the
/nuki/accountspage is attached to it. php artisan nuki:user-create --account=office --account=workshopattaches the new main user to existing accounts. An unknown key is an error, and nothing is created then.
Everything else is still yours to attach: an account made before 1.3.0 or in code, a second main user on an existing account, a sub user who needs an account their parent does not have. If you attached users by hand before 1.3.0, nothing changes for you; those rows are left as they are. In php artisan tinker or in a seeder:
use Darvis\Nuki\Models\NukiAccount;
use Darvis\Nuki\Models\NukiUser;
$user = NukiUser::firstWhere('email', 'admin@example.com');
$account = NukiAccount::findByKey('tenant-42');
$user->accounts()->syncWithoutDetaching([
$account->id => ['role' => 'owner'],
]);
The account shows up in the user’s switcher on the next page load. Only accounts with is_active true count.
Sub users inherit account access from their parent via NukiUser::accessibleAccounts(), so you usually only need to assign accounts to mains. Direct sub assignments are still respected if you create them.
Account switching at runtime
UsesNukiAccount trait, used by AccountSwitcher and every account-aware component:
- Stores the active account key in
session('nuki.current_account'). AccountSwitcherdispatches thenuki-account-changedLivewire event.- Listening components use
#[On('nuki-account-changed')]and reset their state when the user switches. - For a package user the trait falls back to the first accessible account, or to
'default'when the user has none. - A package user can only switch to
defaultor to an accessible account; anything else is a403.