Add Timeclock settings tab in Company Settings with multi-kiosk support
Settings tab (Company Settings > Timeclock): - Enable/disable timeclock toggle (hides nav link and attendance report when off) - Allow multiple clock-ins per day toggle - Auto clock-out after X hours (auto-closes forgotten open entries on next punch) - Kiosk devices table: lists activated tablets with name, activated date, last seen; Deactivate button removes that device's access immediately Multi-kiosk support (replaces single TimeclockKioskToken on Company): - New TimeclockKioskDevice entity (one row per tablet, unique token, DeviceName, LastSeenAt) - KioskActivate GET shows a form for optional device name before activating - KioskDeactivate POST accepts device ID, deletes specific row (not all devices) - Kiosk validation (Kiosk, KioskEmployees, KioskPunch) queries device table with ignoreQueryFilters since no user is logged in on kiosk requests - LastSeenAt updated on each Kiosk page load Enforcement: - ClockIn and KioskPunch both auto-close stale entries if AutoClockOutHours is set - ClockIn and KioskPunch both block second same-day punch if AllowMultiplePunches=false - TimeclockEnabled=false hides nav link (SubscriptionMiddleware sets Items key) and returns Forbid on kiosk punch - Migration: AddTimeclockSettings (adds 3 columns to Companies, new TimeclockKioskDevices table) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -131,24 +131,59 @@ public class TimeclockController : Controller
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <summary>Clocks the current user in. Returns an error if already clocked in.</summary>
|
||||
/// <summary>
|
||||
/// Clocks the current user in. Enforces three company-level timeclock settings:
|
||||
/// (1) auto-closes stale open entries if AutoClockOutHours is set,
|
||||
/// (2) blocks a second punch today if AllowMultiplePunchesPerDay is false,
|
||||
/// (3) blocks immediately if already clocked in.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> ClockIn([FromBody] ClockInRequest request)
|
||||
{
|
||||
var userId = _userManager.GetUserId(User)!;
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (company == null) return Forbid();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Auto clock-out stale open entries if configured
|
||||
if (company.TimeclockAutoClockOutHours is > 0)
|
||||
{
|
||||
var cutoff = now.AddHours(-company.TimeclockAutoClockOutHours.Value);
|
||||
var stale = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||
e => e.UserId == userId && e.ClockOutTime == null
|
||||
&& e.CompanyId == companyId && e.ClockInTime <= cutoff);
|
||||
foreach (var staleEntry in stale)
|
||||
{
|
||||
staleEntry.ClockOutTime = staleEntry.ClockInTime.AddHours(company.TimeclockAutoClockOutHours.Value);
|
||||
staleEntry.HoursWorked = company.TimeclockAutoClockOutHours.Value;
|
||||
staleEntry.Notes = (staleEntry.Notes + " [Auto clocked out]").Trim();
|
||||
}
|
||||
if (stale.Any()) await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
// Still clocked in?
|
||||
var alreadyOpen = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
|
||||
e => e.UserId == userId && e.ClockOutTime == null && e.CompanyId == companyId);
|
||||
|
||||
if (alreadyOpen != null)
|
||||
return BadRequest(new { message = "You are already clocked in. Please clock out first." });
|
||||
|
||||
// Block second punch today if setting disallows it
|
||||
if (!company.TimeclockAllowMultiplePunchesPerDay)
|
||||
{
|
||||
var todayStart = now.Date;
|
||||
var punchedToday = await _unitOfWork.EmployeeClockEntries.AnyAsync(
|
||||
e => e.UserId == userId && e.CompanyId == companyId && e.ClockInTime >= todayStart);
|
||||
if (punchedToday)
|
||||
return BadRequest(new { message = "Multiple clock-ins per day are not allowed. You have already clocked in today." });
|
||||
}
|
||||
|
||||
var entry = new EmployeeClockEntry
|
||||
{
|
||||
UserId = userId,
|
||||
CompanyId = companyId,
|
||||
ClockInTime = DateTime.UtcNow,
|
||||
ClockInTime = now,
|
||||
Notes = request.Notes?.Trim()
|
||||
};
|
||||
|
||||
@@ -264,64 +299,109 @@ public class TimeclockController : Controller
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Manager activates a tablet device by navigating to this URL.
|
||||
/// Generates (or regenerates) the company's TimeclockKioskToken and writes the device cookie.
|
||||
/// Shows the kiosk activation form where a manager can optionally name the device
|
||||
/// before activating it. Navigate to this URL on each tablet to register it.
|
||||
/// </summary>
|
||||
[HttpGet("Timeclock/Kiosk/Activate")]
|
||||
public async Task<IActionResult> KioskActivate()
|
||||
{
|
||||
if (!await IsManagerAsync()) return Forbid();
|
||||
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (company == null) return NotFound();
|
||||
|
||||
company.TimeclockKioskToken = Guid.NewGuid().ToString("N");
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
WriteTimeclockKioskCookie(companyId, company.TimeclockKioskToken);
|
||||
|
||||
TempData["Success"] = "Timeclock kiosk activated on this device.";
|
||||
return RedirectToAction(nameof(Kiosk));
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>Deactivates the timeclock kiosk — clears the token and removes the cookie.</summary>
|
||||
[HttpPost("Timeclock/Kiosk/Deactivate")]
|
||||
public async Task<IActionResult> KioskDeactivate()
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="TimeclockKioskDevice"/> row and writes the device cookie for
|
||||
/// this tablet. Multiple devices per company are supported — each tablet gets its own token.
|
||||
/// </summary>
|
||||
[HttpPost("Timeclock/Kiosk/Activate")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> KioskActivatePost(string? deviceName)
|
||||
{
|
||||
if (!await IsManagerAsync()) return Forbid();
|
||||
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
if (company != null)
|
||||
{
|
||||
company.TimeclockKioskToken = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
|
||||
var device = new TimeclockKioskDevice
|
||||
{
|
||||
CompanyId = companyId,
|
||||
Token = token,
|
||||
DeviceName = string.IsNullOrWhiteSpace(deviceName) ? null : deviceName.Trim(),
|
||||
ActivatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.TimeclockKioskDevices.AddAsync(device);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
WriteTimeclockKioskCookie(companyId, token);
|
||||
|
||||
TempData["Success"] = "Kiosk activated on this device.";
|
||||
return RedirectToAction(nameof(Kiosk));
|
||||
}
|
||||
|
||||
/// <summary>Deactivates a specific kiosk device by ID. Deletes its row so the cookie is invalidated on next use.</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> KioskDeactivate(int id)
|
||||
{
|
||||
if (!await IsManagerAsync()) return Json(new { success = false, message = "Forbidden." });
|
||||
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var device = await _unitOfWork.TimeclockKioskDevices.GetByIdAsync(id);
|
||||
if (device == null || device.CompanyId != companyId)
|
||||
return Json(new { success = false, message = "Device not found." });
|
||||
|
||||
await _unitOfWork.TimeclockKioskDevices.DeleteAsync(device);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// If the current browser holds this device's cookie, clear it too
|
||||
var cookie = ReadTimeclockKioskCookie();
|
||||
if (cookie?.token == device.Token)
|
||||
DeleteTimeclockKioskCookie();
|
||||
|
||||
DeleteTimeclockKioskCookie();
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>Returns the list of active kiosk devices for this company. Used by the Settings tab.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> KioskDevices()
|
||||
{
|
||||
if (!await IsManagerAsync()) return Forbid();
|
||||
|
||||
var companyId = GetCurrentCompanyId();
|
||||
var devices = await _unitOfWork.TimeclockKioskDevices.FindAsync(
|
||||
d => d.CompanyId == companyId);
|
||||
|
||||
var result = devices.OrderBy(d => d.ActivatedAt).Select(d => new
|
||||
{
|
||||
id = d.Id,
|
||||
deviceName = d.DeviceName ?? "Unnamed Device",
|
||||
activatedAt = d.ActivatedAt,
|
||||
lastSeenAt = d.LastSeenAt
|
||||
});
|
||||
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// KIOSK — tablet UI
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Tablet timeclock kiosk page. Requires device-cookie auth; no user login needed.
|
||||
/// Shows employee tiles for all active employees who have a KioskPin set.
|
||||
/// Validates the cookie token against <see cref="TimeclockKioskDevice"/> rows (supports multiple tablets per company).
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("Timeclock/Kiosk")]
|
||||
public async Task<IActionResult> Kiosk()
|
||||
{
|
||||
var cookie = ReadTimeclockKioskCookie();
|
||||
if (cookie == null)
|
||||
var (device, company) = await ValidateKioskCookieAsync();
|
||||
if (device == null || company == null)
|
||||
return View("KioskError", "This device is not activated as a timeclock kiosk. Ask a manager to activate it at Timeclock › Activate Kiosk.");
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
||||
if (company == null || company.TimeclockKioskToken != cookie.Value.token)
|
||||
return View("KioskError", "Kiosk activation is invalid or has been revoked. Ask a manager to re-activate this device.");
|
||||
if (!company.TimeclockEnabled)
|
||||
return View("KioskError", "Timeclock is currently disabled for this company.");
|
||||
|
||||
await TouchDeviceAsync(device);
|
||||
|
||||
ViewBag.CompanyName = company.CompanyName;
|
||||
ViewBag.CompanyId = company.Id;
|
||||
@@ -338,6 +418,12 @@ public class TimeclockController : Controller
|
||||
var cookie = ReadTimeclockKioskCookie();
|
||||
if (cookie == null) return Forbid();
|
||||
|
||||
// Validate device (no need to touch LastSeenAt on every poll)
|
||||
var device = await _unitOfWork.TimeclockKioskDevices.FirstOrDefaultAsync(
|
||||
d => d.CompanyId == cookie.Value.companyId && d.Token == cookie.Value.token,
|
||||
true);
|
||||
if (device == null) return Forbid();
|
||||
|
||||
var companyId = cookie.Value.companyId;
|
||||
|
||||
var users = await _userManager.Users
|
||||
@@ -373,6 +459,13 @@ public class TimeclockController : Controller
|
||||
|
||||
var companyId = cookie.Value.companyId;
|
||||
|
||||
var device = await _unitOfWork.TimeclockKioskDevices.FirstOrDefaultAsync(
|
||||
d => d.CompanyId == companyId && d.Token == cookie.Value.token, true);
|
||||
if (device == null) return Forbid();
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
|
||||
if (company == null || !company.TimeclockEnabled) return Forbid();
|
||||
|
||||
var user = await _userManager.FindByIdAsync(request.UserId);
|
||||
if (user == null || user.CompanyId != companyId || !user.IsActive || user.KioskPin == null)
|
||||
return BadRequest(new { message = "Employee not found or kiosk disabled." });
|
||||
@@ -384,6 +477,22 @@ public class TimeclockController : Controller
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Auto clock-out stale open entries (shared logic with main-app ClockIn)
|
||||
if (company.TimeclockAutoClockOutHours is > 0)
|
||||
{
|
||||
var cutoff = now.AddHours(-company.TimeclockAutoClockOutHours.Value);
|
||||
var stale = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||
e => e.UserId == user.Id && e.ClockOutTime == null
|
||||
&& e.CompanyId == companyId && e.ClockInTime <= cutoff);
|
||||
foreach (var s in stale)
|
||||
{
|
||||
s.ClockOutTime = s.ClockInTime.AddHours(company.TimeclockAutoClockOutHours.Value);
|
||||
s.HoursWorked = company.TimeclockAutoClockOutHours.Value;
|
||||
s.Notes = (s.Notes + " [Auto clocked out]").Trim();
|
||||
}
|
||||
if (stale.Any()) await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
// Check for open entry
|
||||
var openEntry = await _unitOfWork.EmployeeClockEntries.FirstOrDefaultAsync(
|
||||
e => e.UserId == user.Id && e.ClockOutTime == null && e.CompanyId == companyId);
|
||||
@@ -402,6 +511,15 @@ public class TimeclockController : Controller
|
||||
}
|
||||
else
|
||||
{
|
||||
// Block second punch today if not allowed
|
||||
if (!company.TimeclockAllowMultiplePunchesPerDay)
|
||||
{
|
||||
var todayPunched = await _unitOfWork.EmployeeClockEntries.AnyAsync(
|
||||
e => e.UserId == user.Id && e.CompanyId == companyId && e.ClockInTime >= now.Date);
|
||||
if (todayPunched)
|
||||
return BadRequest(new { message = "Multiple clock-ins per day are not allowed." });
|
||||
}
|
||||
|
||||
// Clock in
|
||||
var entry = new EmployeeClockEntry
|
||||
{
|
||||
@@ -468,6 +586,33 @@ public class TimeclockController : Controller
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Validates the kiosk device cookie against the <see cref="TimeclockKioskDevice"/> table.
|
||||
/// Returns (null, null) if the cookie is absent or the token no longer exists.
|
||||
/// Queries with ignoreQueryFilters because the kiosk runs without a logged-in user context.
|
||||
/// </summary>
|
||||
private async Task<(TimeclockKioskDevice? device, Company? company)> ValidateKioskCookieAsync()
|
||||
{
|
||||
var cookie = ReadTimeclockKioskCookie();
|
||||
if (cookie == null) return (null, null);
|
||||
|
||||
var device = await _unitOfWork.TimeclockKioskDevices.FirstOrDefaultAsync(
|
||||
d => d.CompanyId == cookie.Value.companyId && d.Token == cookie.Value.token,
|
||||
true);
|
||||
if (device == null) return (null, null);
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
||||
return (device, company);
|
||||
}
|
||||
|
||||
/// <summary>Updates <see cref="TimeclockKioskDevice.LastSeenAt"/> without blocking the response.</summary>
|
||||
private async Task TouchDeviceAsync(TimeclockKioskDevice device)
|
||||
{
|
||||
device.LastSeenAt = DateTime.UtcNow;
|
||||
await _unitOfWork.TimeclockKioskDevices.UpdateAsync(device);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
private int GetCurrentCompanyId()
|
||||
{
|
||||
var claim = User.FindFirst("CompanyId")?.Value;
|
||||
|
||||
Reference in New Issue
Block a user