diff --git a/.gitignore b/.gitignore
index d1ddcc3..626194c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
.tool-versions
.yarn-integrity
.DS_Store
+.installed
/log/*
/tmp/*
/storage/*
diff --git a/README.md b/README.md
index b1c50e8..80f2bdc 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,20 @@ When translators disagree on a translation or want to suggest alternatives (regi
---
+## Setup / First User
+
+When the file `.installed` is missing, the `/setup` route is accessible for creating the initial admin account. The first user created will be the system's default contact email (accessible via `User.first.email`).
+
+For detailed setup instructions, see [SETUP_GUIDE.md](docs/SETUP_GUIDE.md).
+
+**Quick Start:**
+1. Deploy the application
+2. Navigate to `/setup`
+3. Create your admin account
+4. Start inviting contributors
+
+---
+
## Authentication Flow
### Public Access
diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb
new file mode 100644
index 0000000..f7f80c4
--- /dev/null
+++ b/app/controllers/admin/base_controller.rb
@@ -0,0 +1,4 @@
+class Admin::BaseController < ApplicationController
+ before_action :require_admin
+ layout "admin"
+end
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
new file mode 100644
index 0000000..9a1cdfb
--- /dev/null
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -0,0 +1,32 @@
+class Admin::DashboardController < Admin::BaseController
+ def index
+ @user_count = User.count
+ @contributor_count = User.contributor.count
+ @reviewer_count = User.reviewer.count
+ @admin_count = User.admin.count
+
+ @entry_count = Entry.count
+ @verified_count = Entry.where(verified: true).count
+ @unverified_count = @entry_count - @verified_count
+
+ @pending_suggestions_count = SuggestedMeaning.pending.count
+ @accepted_suggestions_count = SuggestedMeaning.accepted.count
+ @rejected_suggestions_count = SuggestedMeaning.rejected.count
+
+ @comment_count = Comment.count
+
+ @recent_users = User.order(created_at: :desc).limit(5)
+ @recent_entries = Entry.order(created_at: :desc).limit(5)
+ @pending_invitations = User.where.not(invitation_token: nil)
+ .where(invitation_accepted_at: nil)
+ .where("invitation_sent_at > ?", 14.days.ago)
+ .count
+
+ @supported_languages = SupportedLanguage.where(active: true).order(:sort_order)
+ @language_completion = @supported_languages.index_with do |language|
+ next 0 if @entry_count.zero?
+
+ (Entry.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
+ end
+ end
+end
diff --git a/app/controllers/admin/invitations_controller.rb b/app/controllers/admin/invitations_controller.rb
new file mode 100644
index 0000000..211635a
--- /dev/null
+++ b/app/controllers/admin/invitations_controller.rb
@@ -0,0 +1,49 @@
+class Admin::InvitationsController < Admin::BaseController
+ def index
+ @pending_invitations = User.where.not(invitation_token: nil)
+ .where(invitation_accepted_at: nil)
+ .order(invitation_sent_at: :desc)
+ @accepted_invitations = User.where.not(invitation_accepted_at: nil)
+ .order(invitation_accepted_at: :desc)
+ .limit(20)
+ end
+
+ def new
+ @invitation = User.new
+ end
+
+ def create
+ @invitation = User.new(invitation_params)
+ @invitation.invitation_token = SecureRandom.urlsafe_base64(32)
+ @invitation.invitation_sent_at = Time.current
+ @invitation.invited_by = current_user
+ @invitation.password = SecureRandom.urlsafe_base64(16)
+
+ if @invitation.save
+ # TODO: Send invitation email
+ # InvitationMailer.invite(@invitation).deliver_later
+
+ redirect_to admin_invitations_path, notice: "Invitation sent to #{@invitation.email}"
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @invitation = User.find(params[:id])
+
+ if @invitation.invitation_accepted_at.present?
+ redirect_to admin_invitations_path, alert: "Cannot cancel an accepted invitation."
+ return
+ end
+
+ @invitation.destroy
+ redirect_to admin_invitations_path, notice: "Invitation cancelled."
+ end
+
+ private
+
+ def invitation_params
+ params.require(:user).permit(:email, :name, :role, :primary_language)
+ end
+end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
new file mode 100644
index 0000000..01ef353
--- /dev/null
+++ b/app/controllers/admin/users_controller.rb
@@ -0,0 +1,45 @@
+class Admin::UsersController < Admin::BaseController
+ before_action :set_user, only: [ :edit, :update, :destroy ]
+
+ def index
+ @users = User.order(created_at: :desc)
+ @users = @users.where(role: params[:role]) if params[:role].present?
+ @users = @users.where("email LIKE ?", "%#{params[:q]}%") if params[:q].present?
+ end
+
+ def edit
+ end
+
+ def update
+ if @user.update(user_params)
+ redirect_to admin_users_path, notice: "User updated successfully."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ if @user == current_user
+ redirect_to admin_users_path, alert: "You cannot delete your own account."
+ return
+ end
+
+ if @user == User.first
+ redirect_to admin_users_path, alert: "Cannot delete the first admin user (system default contact)."
+ return
+ end
+
+ @user.destroy
+ redirect_to admin_users_path, notice: "User deleted successfully."
+ end
+
+ private
+
+ def set_user
+ @user = User.find(params[:id])
+ end
+
+ def user_params
+ params.require(:user).permit(:name, :email, :role, :primary_language)
+ end
+end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index c353756..e746ab7 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,7 +1,57 @@
class ApplicationController < ActionController::Base
- # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
- allow_browser versions: :modern
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
+
+ helper_method :current_user, :logged_in?, :admin?, :reviewer_or_admin?, :contributor_or_above?, :setup_completed?
+
+ private
+
+ def current_user
+ @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
+ end
+
+ def logged_in?
+ current_user.present?
+ end
+
+ def admin?
+ logged_in? && current_user.admin?
+ end
+
+ def reviewer_or_admin?
+ logged_in? && (current_user.reviewer? || current_user.admin?)
+ end
+
+ def contributor_or_above?
+ logged_in?
+ end
+
+ def require_login
+ unless logged_in?
+ redirect_to login_path, alert: "You must be logged in to access this page."
+ end
+ end
+
+ def require_admin
+ unless admin?
+ redirect_to root_path, alert: "You must be an administrator to access this page."
+ end
+ end
+
+ def require_reviewer
+ unless reviewer_or_admin?
+ redirect_to root_path, alert: "You must be a reviewer or administrator to access this page."
+ end
+ end
+
+ def require_contributor
+ unless contributor_or_above?
+ redirect_to root_path, alert: "You must be a contributor to access this page."
+ end
+ end
+
+ def setup_completed?
+ File.exist?(Rails.root.join(".installed"))
+ end
end
diff --git a/app/controllers/setup_controller.rb b/app/controllers/setup_controller.rb
new file mode 100644
index 0000000..d250be0
--- /dev/null
+++ b/app/controllers/setup_controller.rb
@@ -0,0 +1,45 @@
+class SetupController < ApplicationController
+ before_action :check_setup_allowed
+
+ def show
+ @user = User.new(role: :admin)
+ end
+
+ def create
+ @user = User.new(user_params)
+ @user.role = :admin
+ @user.invitation_accepted_at = Time.current
+
+ if @user.save
+ create_installed_marker
+ session[:user_id] = @user.id
+ redirect_to admin_root_path, notice: "Setup complete! Welcome to Sanasto Wiki."
+ else
+ render :show, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def check_setup_allowed
+ if setup_completed?
+ redirect_to root_path, alert: "Setup has already been completed."
+ end
+ end
+
+ def setup_completed?
+ File.exist?(installed_marker_path)
+ end
+
+ def installed_marker_path
+ Rails.root.join(".installed")
+ end
+
+ def create_installed_marker
+ FileUtils.touch(installed_marker_path)
+ end
+
+ def user_params
+ params.require(:user).permit(:email, :name, :password, :password_confirmation, :primary_language)
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index a20f371..a342752 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -20,4 +20,5 @@ class User < ApplicationRecord
enum :role, %i[contributor reviewer admin]
validates :email, presence: true, uniqueness: true
+ validates :password, length: { minimum: 12 }, if: -> { password.present? }
end
diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb
new file mode 100644
index 0000000..dda36ed
--- /dev/null
+++ b/app/views/admin/dashboard/index.html.erb
@@ -0,0 +1,204 @@
+<% content_for :title, "Dashboard" %>
+
+
+
+
Dashboard
+
Overview of Sanasto Wiki statistics
+
+
+
+
+
+
+
+
+
+
+
+ Total Users
+ <%= @user_count %>
+
+
+
+
+
+
+ <%= @admin_count %> admins,
+ <%= @reviewer_count %> reviewers,
+ <%= @contributor_count %> contributors
+
+
+
+
+
+
+
+
+
+
+
+ Total Entries
+ <%= @entry_count %>
+
+
+
+
+
+
+ <%= @verified_count %> verified,
+ <%= @unverified_count %> unverified
+
+
+
+
+
+
+
+
+
+
+
+ Suggestions
+ <%= @pending_suggestions_count %>
+
+
+
+
+
+
+ <%= @accepted_suggestions_count %> accepted,
+ <%= @rejected_suggestions_count %> rejected
+
+
+
+
+
+
+
+
+
+
+
+ Pending Invites
+ <%= @pending_invitations %>
+
+
+
+
+
+
+ <%= link_to "Send invitation", new_admin_invitation_path, class: "font-medium text-blue-600 hover:text-blue-500" %>
+
+
+
+
+
+
+
+
+
Language Completion
+
+
+ <% @supported_languages.each do |language| %>
+
+
+ <%= language.native_name %>
+ <%= @language_completion[language] %>%
+
+
+
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+
Recent Users
+
+
+ <% @recent_users.each do |user| %>
+
+
+
+
+ <%= user.name || user.email %>
+
+
+ <%= user.email %>
+
+
+
+
+ <%= user.role %>
+
+
+
+
+ <% end %>
+
+
+
+ <%= link_to "View all users →", admin_users_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
+
+
+
+
+
+
+
+
Recent Entries
+
+
+ <% @recent_entries.each do |entry| %>
+
+
+
+
+ <%= entry.fi || entry.en || entry.sv || "(No translation)" %>
+
+
+ <%= entry.category.humanize %>
+
+
+
+ <% if entry.verified? %>
+
+ verified
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+
+ <%= link_to "View all entries →", entries_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
+
+
+
+
+
diff --git a/app/views/admin/invitations/index.html.erb b/app/views/admin/invitations/index.html.erb
new file mode 100644
index 0000000..9b00a76
--- /dev/null
+++ b/app/views/admin/invitations/index.html.erb
@@ -0,0 +1,130 @@
+<% content_for :title, "Invitations" %>
+
+
+
+
+
Invitations
+
Manage user invitations
+
+
+ <%= link_to "Send New Invitation", new_admin_invitation_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
+
+
+
+
+
+
+
+ Pending Invitations
+
+ <%= @pending_invitations.count %>
+
+
+
+ <% if @pending_invitations.any? %>
+
+
+
+ Email
+ Role
+ Sent
+ Expires
+ Invited By
+
+ Actions
+
+
+
+
+ <% @pending_invitations.each do |invitation| %>
+
+
+ <%= invitation.email %>
+
+
+
+ <%= invitation.role %>
+
+
+
+ <%= invitation.invitation_sent_at.strftime("%b %d, %Y") %>
+
+
+ <% expires_at = invitation.invitation_sent_at + 14.days %>
+ <% if expires_at < Time.current %>
+ Expired
+ <% else %>
+ <%= expires_at.strftime("%b %d, %Y") %>
+ <% end %>
+
+
+ <%= invitation.invited_by&.name || invitation.invited_by&.email || "-" %>
+
+
+ <%= button_to "Cancel", admin_invitation_path(invitation), method: :delete, data: { turbo_confirm: "Are you sure you want to cancel this invitation?" }, class: "text-red-600 hover:text-red-900" %>
+
+
+ <% end %>
+
+
+ <% else %>
+
+
No pending invitations
+
+ <% end %>
+
+
+
+
+
+
Recently Accepted
+
+ <% if @accepted_invitations.any? %>
+
+
+
+ User
+ Role
+ Accepted
+ Invited By
+
+
+
+ <% @accepted_invitations.each do |user| %>
+
+
+ <%= user.name || "(No name)" %>
+ <%= user.email %>
+
+
+
+ <%= user.role %>
+
+
+
+ <%= user.invitation_accepted_at.strftime("%b %d, %Y") %>
+
+
+ <%= user.invited_by&.name || user.invited_by&.email || "-" %>
+
+
+ <% end %>
+
+
+ <% else %>
+
+
No accepted invitations yet
+
+ <% end %>
+
+
diff --git a/app/views/admin/invitations/new.html.erb b/app/views/admin/invitations/new.html.erb
new file mode 100644
index 0000000..f7d5f73
--- /dev/null
+++ b/app/views/admin/invitations/new.html.erb
@@ -0,0 +1,78 @@
+<% content_for :title, "Send Invitation" %>
+
+
+
+ <%= link_to "← Back to Invitations", admin_invitations_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
+
+
+
+
Send Invitation
+
Invite a new user to join Sanasto Wiki
+
+
+
+
+ <%= form_with model: @invitation, url: admin_invitations_path, class: "space-y-6" do |f| %>
+ <% if @invitation.errors.any? %>
+
+
+
+
+ <%= pluralize(@invitation.errors.count, "error") %> prohibited this invitation from being sent:
+
+
+
+ <% @invitation.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+
+
+
+ <% end %>
+
+
+ <%= f.label :email, class: "block text-sm font-medium text-gray-700" %>
+ <%= f.email_field :email, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "user@example.com" %>
+
The user will receive an invitation email with a link to register.
+
+
+
+ <%= f.label :name, "Name (optional)", class: "block text-sm font-medium text-gray-700" %>
+ <%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "John Doe" %>
+
+
+
+ <%= f.label :role, class: "block text-sm font-medium text-gray-700" %>
+ <%= f.select :role, User.roles.keys.map { |r| [r.humanize, r] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
+
+ Contributor: Can create/edit entries and comments.
+ Reviewer: Can review suggestions and verify entries.
+ Admin: Full access to all features.
+
+
+
+
+ <%= f.label :primary_language, "Primary Language (optional)", class: "block text-sm font-medium text-gray-700" %>
+ <%= f.select :primary_language, SupportedLanguage.pluck(:code, :native_name), { include_blank: "Select language" }, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
+
+
+
+
+
+
+ Note: The invitation link will expire in 14 days. The user will need to accept the invitation before the expiration date.
+
+
+
+
+
+
+ <%= link_to "Cancel", admin_invitations_path, class: "px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
+ <%= f.submit "Send Invitation", class: "inline-flex justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
+
+ <% end %>
+
+
+
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb
new file mode 100644
index 0000000..fd68e3a
--- /dev/null
+++ b/app/views/admin/users/edit.html.erb
@@ -0,0 +1,62 @@
+<% content_for :title, "Edit User" %>
+
+
+
+ <%= link_to "← Back to Users", admin_users_path, class: "text-sm font-medium text-blue-600 hover:text-blue-500" %>
+
+
+
+
Edit User
+
Update user information and permissions
+
+
+
+
+ <%= form_with model: @user, url: admin_user_path(@user), method: :patch, class: "space-y-6" do |f| %>
+ <% if @user.errors.any? %>
+
+
+
+
+ <%= pluralize(@user.errors.count, "error") %> prohibited this user from being saved:
+
+
+
+ <% @user.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+
+
+
+ <% end %>
+
+
+ <%= f.label :name, class: "block text-sm font-medium text-gray-700" %>
+ <%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
+
+
+
+ <%= f.label :email, class: "block text-sm font-medium text-gray-700" %>
+ <%= f.email_field :email, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
+
+
+
+ <%= f.label :role, class: "block text-sm font-medium text-gray-700" %>
+ <%= f.select :role, User.roles.keys.map { |r| [r.humanize, r] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
+
+
+
+ <%= f.label :primary_language, class: "block text-sm font-medium text-gray-700" %>
+ <%= f.select :primary_language, SupportedLanguage.pluck(:code, :native_name), { include_blank: "Select language" }, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
+
+
+
+ <%= link_to "Cancel", admin_users_path, class: "px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
+ <%= f.submit "Update User", class: "inline-flex justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
+
+ <% end %>
+
+
+
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb
new file mode 100644
index 0000000..987cd05
--- /dev/null
+++ b/app/views/admin/users/index.html.erb
@@ -0,0 +1,97 @@
+<% content_for :title, "Users" %>
+
+
+
+
+
User Management
+
Manage user accounts and permissions
+
+
+ <%= link_to "Send Invitation", new_admin_invitation_path, class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
+
+
+
+
+
+ <%= form_with url: admin_users_path, method: :get, class: "flex gap-4" do |f| %>
+
+ <%= f.text_field :q, value: params[:q], placeholder: "Search by email...", class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
+
+
+ <%= f.select :role, options_for_select([["All Roles", ""], ["Contributors", "contributor"], ["Reviewers", "reviewer"], ["Admins", "admin"]], params[:role]), {}, class: "block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
+
+
+ <%= f.submit "Filter", class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700" %>
+
+ <% end %>
+
+
+
+
+
+
+
+ User
+ Role
+ Primary Language
+ Joined
+ Status
+
+ Actions
+
+
+
+
+ <% @users.each do |user| %>
+
+
+
+
+
+ <%= user.name || "(No name)" %>
+
+
+ <%= user.email %>
+
+
+
+
+
+
+ <%= user.role %>
+
+
+
+ <%= user.primary_language&.upcase || "-" %>
+
+
+ <%= user.invitation_accepted_at&.strftime("%b %d, %Y") || user.created_at.strftime("%b %d, %Y") %>
+
+
+ <% if user.invitation_accepted_at.present? %>
+
+ Active
+
+ <% else %>
+
+ Invited
+
+ <% end %>
+
+
+ <%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900 mr-4" %>
+ <% if user != current_user && user != User.first %>
+ <%= button_to "Delete", admin_user_path(user), method: :delete, data: { turbo_confirm: "Are you sure you want to delete this user?" }, class: "text-red-600 hover:text-red-900" %>
+ <% end %>
+
+
+ <% end %>
+
+
+
+
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
new file mode 100644
index 0000000..7b504dd
--- /dev/null
+++ b/app/views/layouts/admin.html.erb
@@ -0,0 +1,60 @@
+
+
+
+ <%= content_for(:title) || "Admin Dashboard" %> - Sanasto Wiki
+
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+ <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
+ <%= javascript_importmap_tags %>
+
+
+
+
+
+
+
+
+
+
+
+ <%= link_to "Sanasto Admin", admin_root_path, class: "hover:text-blue-100" %>
+
+
+
+ <%= link_to "Dashboard", admin_dashboard_path, class: "px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700" %>
+ <%= link_to "Users", admin_users_path, class: "px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700" %>
+ <%= link_to "Invitations", admin_invitations_path, class: "px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700" %>
+ <%= link_to "Back to Site", root_path, class: "px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700" %>
+
+
+
+
+
+
+ <% if flash.any? %>
+
+ <% flash.each do |type, message| %>
+
+ <%= message %>
+
+ <% end %>
+
+ <% end %>
+
+
+
+ <%= yield %>
+
+
+
+
+
+
+
diff --git a/app/views/setup/show.html.erb b/app/views/setup/show.html.erb
new file mode 100644
index 0000000..9890419
--- /dev/null
+++ b/app/views/setup/show.html.erb
@@ -0,0 +1,107 @@
+
+
+
+ Setup - Sanasto Wiki
+
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+ <%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
+ <%= javascript_importmap_tags %>
+
+
+
+
+
+
+
+
+
+
Sanasto Wiki
+
Initial Setup
+
+
+
+
+
+
Create Admin Account
+
+ Welcome! Let's create your administrator account to get started.
+
+
+
+ <%= form_with model: @user, url: setup_path, method: :post, class: "space-y-6" do |f| %>
+ <% if @user.errors.any? %>
+
+
+
+
+
+ <%= pluralize(@user.errors.count, "error") %> prevented setup:
+
+
+
+ <% @user.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+
+
+
+ <% end %>
+
+
+ <%= f.label :name, class: "block text-sm font-medium text-gray-700" %>
+ <%= f.text_field :name, required: true, autofocus: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Your full name" %>
+
+
+
+ <%= f.label :email, class: "block text-sm font-medium text-gray-700" %>
+ <%= f.email_field :email, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "admin@example.com" %>
+
This will be the default contact email for the system.
+
+
+
+ <%= f.label :primary_language, "Preferred Language", class: "block text-sm font-medium text-gray-700" %>
+ <%= f.select :primary_language, SupportedLanguage.order(:sort_order).pluck(:code, :native_name), { include_blank: "Select your primary language" }, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
+
+
+
+ <%= f.label :password, class: "block text-sm font-medium text-gray-700" %>
+ <%= f.password_field :password, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Minimum 12 characters" %>
+
Choose a strong password with at least 12 characters.
+
+
+
+ <%= f.label :password_confirmation, "Confirm Password", class: "block text-sm font-medium text-gray-700" %>
+ <%= f.password_field :password_confirmation, required: true, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Re-enter your password" %>
+
+
+
+
+
+
+
+ Note: This account will have full administrator access and cannot be deleted through the UI.
+
+
+
+
+
+
+ <%= f.submit "Complete Setup", class: "w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all duration-200" %>
+
+ <% end %>
+
+
+
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 561dca5..a74e826 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -12,6 +12,23 @@ Rails.application.routes.draw do
# Defines the root path route ("/")
root "entries#index"
+ # Setup route (only accessible when .installed file doesn't exist)
+ get "setup", to: "setup#show"
+ post "setup", to: "setup#create"
+
+ # Authentication routes (placeholder for future implementation)
+ # get "login", to: "sessions#new", as: :login
+ # post "login", to: "sessions#create"
+ # delete "logout", to: "sessions#destroy", as: :logout
+
+ # Admin namespace
+ namespace :admin do
+ root "dashboard#index"
+ get "dashboard", to: "dashboard#index"
+ resources :users, only: [ :index, :edit, :update, :destroy ]
+ resources :invitations, only: [ :index, :new, :create, :destroy ]
+ end
+
resources :entries do
collection do
get :download
diff --git a/docs/ADMIN.md b/docs/ADMIN.md
new file mode 100644
index 0000000..950f470
--- /dev/null
+++ b/docs/ADMIN.md
@@ -0,0 +1,34 @@
+# Admin Dashboard
+
+The admin dashboard provides tools for managing users, invitations, and viewing system statistics.
+
+## Creating the First Admin User
+
+Visit /setup
+
+## Accessing the Admin Dashboard
+
+1. **Note:** Authentication system is not yet implemented. You'll need to set `session[:user_id]` manually in the console or implement the authentication controllers first.
+
+2. Once authenticated, navigate to `/admin` to access the dashboard.
+
+## Admin Features
+
+### Dashboard (`/admin`)
+- View system statistics (users, entries, suggestions)
+- Language completion percentages
+- Recent activity (new users, new entries)
+- Pending invitations count
+
+### User Management (`/admin/users`)
+- List all users with filtering
+- Edit user details and roles
+- Delete users (except yourself)
+- View user status (active/invited)
+
+### Invitations (`/admin/invitations`)
+- Send new invitations
+- View pending invitations (not yet accepted)
+- Cancel pending invitations
+- View recently accepted invitations
+- Invitations expire after 14 days
diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md
new file mode 100644
index 0000000..0b3dc1b
--- /dev/null
+++ b/docs/SETUP_GUIDE.md
@@ -0,0 +1,74 @@
+# Setup Guide
+
+## Initial Setup
+
+When you first deploy Sanasto Wiki, you need to create an initial administrator account.
+
+### Automatic Setup (Recommended)
+
+1. Start the Rails server:
+ ```bash
+ bundle exec rails server
+ ```
+
+2. Navigate to `/setup` in your browser
+
+3. Fill in the setup form:
+ - **Name**: Your full name
+ - **Email**: Your email address (becomes the system default contact)
+ - **Preferred Language**: Your primary working language
+ - **Password**: At least 12 characters
+ - **Confirm Password**: Re-enter your password
+
+4. Click "Complete Setup"
+
+5. You'll be automatically logged in and redirected to the admin dashboard
+
+### What Happens During Setup
+
+- Creates your admin account with full permissions
+- Sets you as the default system contact (User.first.email)
+- Creates a `.installed` marker file to prevent re-running setup
+- Automatically logs you in
+- Protects your account from deletion (first user cannot be deleted)
+
+### After Setup
+
+Once setup is complete:
+- The `/setup` route becomes inaccessible
+- You can access the admin dashboard at `/admin`
+- You can invite other users through the admin interface
+- The first admin user (you) is protected from deletion
+
+### Resetting Setup
+
+If you need to re-run setup (e.g., in development):
+
+```bash
+# Remove the installed marker
+rm .installed
+
+# Clear the database (development only!)
+bundle exec rails db:reset
+
+# Now you can access /setup again
+```
+
+### Production Deployment
+
+For production deployment:
+1. Deploy the application
+2. Run migrations: `bundle exec rails db:migrate`
+3. Navigate to your domain's `/setup` route
+4. Complete the setup form
+5. Start inviting contributors
+
+The `.installed` file should NOT be committed to version control (it's in .gitignore).
+
+### Security Notes
+
+- The setup route is only accessible when `.installed` file doesn't exist
+- Password must be at least 12 characters
+- The first admin user cannot be deleted through the UI
+- Setup automatically creates an admin-level account
+- After setup, use the invitation system to add more users
diff --git a/docs/TODO.md b/docs/TODO.md
index 357119d..6db798a 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -54,10 +54,11 @@
## User Management
-- [ ] **Admin dashboard**
- - [ ] Send invitations by email
- - [ ] Manage users (list, edit roles, deactivate)
- - [ ] System statistics (users, entries, contributions)
+- [x] **Setup** adds the first user
+- [x] **Admin dashboard**
+ - [x] Send invitations interface (email delivery pending mailer implementation)
+ - [x] Manage users (list, edit roles, delete)
+ - [x] System statistics (users, entries, contributions)
- [ ] **User profile page**
- [ ] Edit name, email, password
- [ ] Set primary language preference
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index 0020ba5..f8b7c32 100644
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -2,33 +2,39 @@ require "test_helper"
class UserTest < ActiveSupport::TestCase
test "should be valid with an email and password" do
- user = User.new(email: "test@example.com", password: "password")
+ user = User.new(email: "test@example.com", password: "password123456")
assert user.valid?
end
test "should be invalid without an email" do
- user = User.new(password: "password")
+ user = User.new(password: "password123456")
assert_not user.valid?
end
test "should be invalid with a duplicate email" do
- User.create(email: "test@example.com", password: "password")
- user = User.new(email: "test@example.com", password: "password")
+ User.create(email: "test@example.com", password: "password123456")
+ user = User.new(email: "test@example.com", password: "password123456")
assert_not user.valid?
end
test "should have a default role of contributor" do
- user = User.new(email: "test@example.com", password: "password")
+ user = User.new(email: "test@example.com", password: "password123456")
assert user.contributor?
end
test "can be a reviewer" do
- user = User.new(email: "test@example.com", password: "password", role: :reviewer)
+ user = User.new(email: "test@example.com", password: "password123456", role: :reviewer)
assert user.reviewer?
end
test "can be an admin" do
- user = User.new(email: "test@example.com", password: "password", role: :admin)
+ user = User.new(email: "test@example.com", password: "password123456", role: :admin)
assert user.admin?
end
+
+ test "should be invalid with a password shorter than 12 characters" do
+ user = User.new(email: "test@example.com", password: "short")
+ assert_not user.valid?
+ assert_includes user.errors[:password], "is too short (minimum is 12 characters)"
+ end
end