1. User Authentication
- Full auth system with email/password (using phx.gen.auth) - Login, registration, password reset - Session management with remember-me functionality - Magic link login support 2. Organization Management - Multi-tenant organization system - Organizations schema with unique slugs - Automatic organization creation when users register - Organization switcher UI at /orgs 3. Membership System - Users can belong to multiple organizations - 4 permission levels: Owner, Admin, Member, Viewer - Complete permission matrix implemented - Join/leave organizations 4. Invitation System - Email-based invitations with secure tokens - 7-day expiration on invites - Track who invited and who accepted 5. Authorization - Full policy system (Organizations.Policy) - can?(membership, :action, :resource) helper - Enforced via plugs in router 6. LiveView Pages - /orgs - List all your organizations - /orgs/new - Create new organization - /orgs/:slug - Organization dashboard (placeholder) 7. Database Schema - users table - organizations table - organization_memberships table - organization_invitations table - All migrations run successfully
This commit is contained in:
parent
20d2d8dbd8
commit
c52f313e2d
46 changed files with 3847 additions and 0 deletions
31
AGENTS.md
31
AGENTS.md
|
|
@ -44,6 +44,37 @@ custom classes must fully style the input
|
|||
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
|
||||
|
||||
|
||||
<!-- phoenix-gen-auth-start -->
|
||||
## Authentication
|
||||
|
||||
- **Always** handle authentication flow at the router level with proper redirects
|
||||
- **Always** be mindful of where to place routes. `phx.gen.auth` creates multiple router plugs:
|
||||
- A plug `:fetch_current_scope_for_user` that is included in the default browser pipeline
|
||||
- A plug `:require_authenticated_user` that redirects to the log in page when the user is not authenticated
|
||||
- In both cases, a `@current_scope` is assigned to the Plug connection
|
||||
- A plug `redirect_if_user_is_authenticated` that redirects to a default path in case the user is authenticated - useful for a registration page that should only be shown to unauthenticated users
|
||||
- **Always let the user know in which router scopes and pipeline you are placing the route, AND SAY WHY**
|
||||
- `phx.gen.auth` assigns the `current_scope` assign - it **does not assign a `current_user` assign**
|
||||
- Always pass the assign `current_scope` to context modules as first argument. When performing queries, use `current_scope.user` to filter the query results
|
||||
- To derive/access `current_user` in templates, **always use the `@current_scope.user`**, never use **`@current_user`** in templates
|
||||
- Anytime you hit `current_scope` errors or the logged in session isn't displaying the right content, **always double check the router and ensure you are using the correct plug as described below**
|
||||
|
||||
### Routes that require authentication
|
||||
|
||||
Controller routes must be placed in a scope that sets the `:require_authenticated_user` plug:
|
||||
|
||||
scope "/", AppWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
get "/", MyControllerThatRequiresAuth, :index
|
||||
end
|
||||
|
||||
### Routes that work with or without authentication
|
||||
|
||||
Controllers automatically have the `current_scope` available if they use the `:browser` pipeline.
|
||||
|
||||
<!-- phoenix-gen-auth-end -->
|
||||
|
||||
<!-- usage-rules-start -->
|
||||
|
||||
<!-- phoenix:elixir-start -->
|
||||
|
|
|
|||
403
IMPLEMENTATION_PLAN.md
Normal file
403
IMPLEMENTATION_PLAN.md
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
# TowerOps - Network Monitoring & Alerting Platform
|
||||
## Implementation Plan
|
||||
|
||||
## Overview
|
||||
Multi-tenant network monitoring and alerting application for tracking network equipment health via ping monitoring.
|
||||
|
||||
## Core Requirements
|
||||
1. User accounts (email as username)
|
||||
2. Organizations (multi-tenant)
|
||||
3. Organization membership with permission levels
|
||||
4. Site hierarchy
|
||||
5. Equipment management (IP-based)
|
||||
6. Automated ping monitoring (5-minute intervals)
|
||||
7. Alerting system
|
||||
|
||||
---
|
||||
|
||||
## Data Model Design
|
||||
|
||||
### Users
|
||||
- `id` (binary_id, PK)
|
||||
- `email` (string, unique, username)
|
||||
- `hashed_password` (string)
|
||||
- `confirmed_at` (utc_datetime, nullable)
|
||||
- `inserted_at` / `updated_at` (utc_datetime)
|
||||
|
||||
### Organizations
|
||||
- `id` (binary_id, PK)
|
||||
- `name` (string)
|
||||
- `slug` (string, unique) - for URLs
|
||||
- `inserted_at` / `updated_at` (utc_datetime)
|
||||
|
||||
### OrganizationMemberships
|
||||
- `id` (binary_id, PK)
|
||||
- `organization_id` (binary_id, FK -> organizations)
|
||||
- `user_id` (binary_id, FK -> users)
|
||||
- `role` (enum: owner, admin, member, viewer)
|
||||
- `inserted_at` / `updated_at` (utc_datetime)
|
||||
- Unique constraint on (organization_id, user_id)
|
||||
|
||||
**Permission Levels:**
|
||||
- `owner` - Full control, can delete org, manage all settings
|
||||
- `admin` - Can manage users, sites, equipment, view all
|
||||
- `member` - Can add/edit equipment, view sites
|
||||
- `viewer` - Read-only access
|
||||
|
||||
### Sites
|
||||
- `id` (binary_id, PK)
|
||||
- `organization_id` (binary_id, FK -> organizations)
|
||||
- `parent_site_id` (binary_id, FK -> sites, nullable) - for hierarchy
|
||||
- `name` (string)
|
||||
- `description` (text, nullable)
|
||||
- `location` (string, nullable)
|
||||
- `inserted_at` / `updated_at` (utc_datetime)
|
||||
|
||||
### Equipment
|
||||
- `id` (binary_id, PK)
|
||||
- `site_id` (binary_id, FK -> sites)
|
||||
- `name` (string)
|
||||
- `ip_address` (string)
|
||||
- `description` (text, nullable)
|
||||
- `status` (enum: up, down, unknown)
|
||||
- `last_checked_at` (utc_datetime, nullable)
|
||||
- `last_status_change_at` (utc_datetime, nullable)
|
||||
- `monitoring_enabled` (boolean, default: true)
|
||||
- `check_interval_seconds` (integer, default: 300) - 5 minutes
|
||||
- `inserted_at` / `updated_at` (utc_datetime)
|
||||
|
||||
### MonitoringChecks (historical log)
|
||||
- `id` (binary_id, PK)
|
||||
- `equipment_id` (binary_id, FK -> equipment)
|
||||
- `status` (enum: success, failure)
|
||||
- `response_time_ms` (integer, nullable)
|
||||
- `checked_at` (utc_datetime)
|
||||
- Index on (equipment_id, checked_at)
|
||||
|
||||
### Alerts
|
||||
- `id` (binary_id, PK)
|
||||
- `equipment_id` (binary_id, FK -> equipment)
|
||||
- `alert_type` (enum: equipment_down, equipment_up)
|
||||
- `triggered_at` (utc_datetime)
|
||||
- `acknowledged_at` (utc_datetime, nullable)
|
||||
- `acknowledged_by_id` (binary_id, FK -> users, nullable)
|
||||
- `resolved_at` (utc_datetime, nullable)
|
||||
- `email_sent_at` (utc_datetime, nullable)
|
||||
- `inserted_at` / `updated_at` (utc_datetime)
|
||||
|
||||
### OrganizationInvitations
|
||||
- `id` (binary_id, PK)
|
||||
- `organization_id` (binary_id, FK -> organizations)
|
||||
- `email` (string)
|
||||
- `role` (enum: admin, member, viewer) - cannot invite as owner
|
||||
- `token` (string, unique) - secure random token for invite link
|
||||
- `invited_by_id` (binary_id, FK -> users)
|
||||
- `accepted_at` (utc_datetime, nullable)
|
||||
- `accepted_by_id` (binary_id, FK -> users, nullable)
|
||||
- `expires_at` (utc_datetime) - invites expire after 7 days
|
||||
- `inserted_at` / `updated_at` (utc_datetime)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Components
|
||||
|
||||
### 1. Authentication & Authorization
|
||||
|
||||
**Use Phoenix.LiveView built-in patterns:**
|
||||
- Email/password authentication
|
||||
- Session-based auth with LiveView
|
||||
- Password reset via email
|
||||
|
||||
**Authorization Strategy:**
|
||||
- Context-based permissions (organization-scoped)
|
||||
- Plugs for organization membership verification
|
||||
- LiveView mount hooks for permission checks
|
||||
- Helper functions: `can?(user, :action, resource)`
|
||||
|
||||
### 2. Multi-Tenancy
|
||||
|
||||
**Organization Scoping:**
|
||||
- All queries scoped by current_organization
|
||||
- LiveView assigns: `@current_user`, `@current_organization`, `@current_membership`
|
||||
- Router organization switcher for users in multiple orgs
|
||||
- URL structure: `/orgs/:org_slug/sites`, `/orgs/:org_slug/equipment`
|
||||
|
||||
### 3. Monitoring System
|
||||
|
||||
**Background Job Architecture:**
|
||||
- Use GenServer or DynamicSupervisor for monitoring workers
|
||||
- One worker per equipment (or batched by site)
|
||||
- Quantum or similar for scheduling (or custom OTP solution)
|
||||
- Use `:gen_icmp` or System.cmd("ping") for ping checks
|
||||
|
||||
**Monitoring Flow:**
|
||||
1. Worker wakes up every N seconds (configurable per equipment)
|
||||
2. Pings equipment IP address
|
||||
3. Records result in monitoring_checks table
|
||||
4. Updates equipment status if changed
|
||||
5. Creates alert if status transitions (up->down or down->up)
|
||||
6. Broadcasts status change via PubSub for real-time UI updates
|
||||
|
||||
### 4. Real-Time Updates
|
||||
|
||||
**Phoenix PubSub Topics:**
|
||||
- `organization:#{org_id}:equipment:#{equipment_id}` - Equipment status
|
||||
- `organization:#{org_id}:alerts` - New alerts
|
||||
- `organization:#{org_id}:sites` - Site changes
|
||||
|
||||
**LiveView Integration:**
|
||||
- Subscribe to relevant topics on mount
|
||||
- Handle PubSub messages to update assigns
|
||||
- Stream-based updates for lists
|
||||
|
||||
### 5. UI Structure
|
||||
|
||||
**LiveView Pages:**
|
||||
- `/login`, `/register` - Authentication
|
||||
- `/orgs` - Organization list/switcher
|
||||
- `/orgs/:slug/dashboard` - Overview, active alerts, recent changes
|
||||
- `/orgs/:slug/sites` - Site hierarchy tree view
|
||||
- `/orgs/:slug/sites/:id` - Site detail with equipment list
|
||||
- `/orgs/:slug/equipment` - All equipment list
|
||||
- `/orgs/:slug/equipment/:id` - Equipment detail with check history
|
||||
- `/orgs/:slug/alerts` - Alert history
|
||||
- `/orgs/:slug/settings` - Org settings, members
|
||||
|
||||
**Components:**
|
||||
- Organization switcher (header)
|
||||
- Site tree navigator
|
||||
- Equipment status badge
|
||||
- Alert list/feed
|
||||
- Permission-based action buttons
|
||||
|
||||
---
|
||||
|
||||
## Implementation Stages
|
||||
|
||||
### Stage 1: Foundation & Authentication
|
||||
**Goal**: User authentication and basic org structure
|
||||
**Success Criteria**:
|
||||
- Users can register/login with email
|
||||
- Users can create organizations
|
||||
- Users can switch between organizations
|
||||
- Basic navigation structure
|
||||
- Tests passing
|
||||
|
||||
**Detailed Tasks**:
|
||||
|
||||
#### 1.1 User Authentication
|
||||
- [ ] Run `mix phx.gen.auth Accounts User users` to scaffold auth system
|
||||
- [ ] Review and customize generated code (email as username)
|
||||
- [ ] Update user registration to create first organization
|
||||
- [ ] Add tests for auth flows
|
||||
|
||||
**Files Created**:
|
||||
- `lib/towerops/accounts.ex` - User context
|
||||
- `lib/towerops/accounts/user.ex` - User schema
|
||||
- `lib/towerops/accounts/user_token.ex` - Session tokens
|
||||
- `lib/towerops_web/user_auth.ex` - Auth plugs
|
||||
- `lib/towerops_web/controllers/user_session_controller.ex`
|
||||
- `lib/towerops_web/controllers/user_registration_controller.ex`
|
||||
- `lib/towerops_web/controllers/user_reset_password_controller.ex`
|
||||
- `lib/towerops_web/controllers/user_settings_controller.ex`
|
||||
- Migrations for users and user_tokens tables
|
||||
|
||||
#### 1.2 Organizations & Memberships
|
||||
- [ ] Create migration: `mix ecto.gen.migration create_organizations`
|
||||
- [ ] Create migration: `mix ecto.gen.migration create_organization_memberships`
|
||||
- [ ] Create migration: `mix ecto.gen.migration create_organization_invitations`
|
||||
- [ ] Create `lib/towerops/organizations.ex` context
|
||||
- [ ] Create `lib/towerops/organizations/organization.ex` schema
|
||||
- [ ] Create `lib/towerops/organizations/membership.ex` schema
|
||||
- [ ] Create `lib/towerops/organizations/invitation.ex` schema
|
||||
- [ ] Add slug generation for organizations (use Ecto changeset)
|
||||
- [ ] Add tests for organizations context
|
||||
|
||||
**Migration Details**:
|
||||
```elixir
|
||||
# organizations table
|
||||
create table(:organizations, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :name, :string, null: false
|
||||
add :slug, :string, null: false
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
create unique_index(:organizations, [:slug])
|
||||
|
||||
# organization_memberships table
|
||||
create table(:organization_memberships, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all)
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all)
|
||||
add :role, :string, null: false
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
create unique_index(:organization_memberships, [:organization_id, :user_id])
|
||||
create index(:organization_memberships, [:user_id])
|
||||
```
|
||||
|
||||
#### 1.3 Multi-Org Navigation
|
||||
- [ ] Create organization switcher LiveView component
|
||||
- [ ] Add current_organization plug to router
|
||||
- [ ] Create `/orgs` LiveView (list user's organizations)
|
||||
- [ ] Create `/orgs/new` LiveView (create organization)
|
||||
- [ ] Update router with organization-scoped routes
|
||||
- [ ] Add breadcrumb navigation component
|
||||
- [ ] Add tests for organization switching
|
||||
|
||||
**Router Structure**:
|
||||
```elixir
|
||||
scope "/", ToweropsWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live "/orgs", OrgLive.Index
|
||||
live "/orgs/new", OrgLive.New
|
||||
end
|
||||
|
||||
scope "/orgs/:org_slug", ToweropsWeb do
|
||||
pipe_through [:browser, :require_authenticated_user, :load_current_organization]
|
||||
|
||||
live "/", DashboardLive
|
||||
# Future: sites, equipment, etc.
|
||||
end
|
||||
```
|
||||
|
||||
#### 1.4 Authorization System
|
||||
- [ ] Create `lib/towerops/organizations/policy.ex` for permission checks
|
||||
- [ ] Add `can?/3` helper function
|
||||
- [ ] Create `:load_current_organization` plug
|
||||
- [ ] Add permission checks to LiveView mount callbacks
|
||||
- [ ] Add tests for authorization
|
||||
|
||||
**Permission Matrix**:
|
||||
| Action | Owner | Admin | Member | Viewer |
|
||||
|--------|-------|-------|--------|--------|
|
||||
| View org | ✓ | ✓ | ✓ | ✓ |
|
||||
| Edit org settings | ✓ | ✓ | ✗ | ✗ |
|
||||
| Delete org | ✓ | ✗ | ✗ | ✗ |
|
||||
| Manage members | ✓ | ✓ | ✗ | ✗ |
|
||||
| Add/edit equipment | ✓ | ✓ | ✓ | ✗ |
|
||||
| View equipment | ✓ | ✓ | ✓ | ✓ |
|
||||
|
||||
#### 1.5 Basic UI & Layouts
|
||||
- [ ] Update `layouts.ex` to include org switcher in header
|
||||
- [ ] Create organization badge component
|
||||
- [ ] Style navigation with Tailwind
|
||||
- [ ] Add flash message styling
|
||||
- [ ] Ensure responsive design
|
||||
|
||||
### Stage 2: Sites & Equipment Management
|
||||
**Goal**: CRUD for sites and equipment
|
||||
**Success Criteria**:
|
||||
- Users can create/edit/delete sites
|
||||
- Sites can have parent sites (hierarchy)
|
||||
- Users can add equipment to sites
|
||||
- Equipment has IP address and basic info
|
||||
|
||||
**Tasks**:
|
||||
1. Generate sites schema and LiveViews
|
||||
2. Build site hierarchy tree component
|
||||
3. Generate equipment schema and LiveViews
|
||||
4. Add IP address validation
|
||||
5. Permission checks on all actions
|
||||
|
||||
### Stage 3: Monitoring System
|
||||
**Goal**: Automated ping monitoring
|
||||
**Success Criteria**:
|
||||
- Equipment is pinged every 5 minutes
|
||||
- Status updates in real-time
|
||||
- Monitoring checks are logged
|
||||
|
||||
**Tasks**:
|
||||
1. Design monitoring worker architecture
|
||||
2. Implement ping functionality
|
||||
3. Create monitoring_checks schema
|
||||
4. Build GenServer workers for monitoring
|
||||
5. Add PubSub broadcasting
|
||||
6. Update LiveView to receive real-time updates
|
||||
|
||||
### Stage 4: Alerting
|
||||
**Goal**: Alert generation and management
|
||||
**Success Criteria**:
|
||||
- Alerts created on status changes
|
||||
- Users can view alert history
|
||||
- Users can acknowledge alerts
|
||||
|
||||
**Tasks**:
|
||||
1. Create alerts schema
|
||||
2. Build alert creation logic in monitoring workers
|
||||
3. Create alert LiveView pages
|
||||
4. Add alert acknowledgment
|
||||
5. Alert notifications in UI
|
||||
|
||||
### Stage 5: Polish & Production
|
||||
**Goal**: Production-ready application
|
||||
**Success Criteria**:
|
||||
- All tests passing
|
||||
- Polished UI with Tailwind
|
||||
- Email notifications configured
|
||||
- Production deployment ready
|
||||
|
||||
**Tasks**:
|
||||
1. Comprehensive test coverage
|
||||
2. UI/UX improvements
|
||||
3. Email alert notifications
|
||||
4. Performance optimization
|
||||
5. Documentation
|
||||
|
||||
---
|
||||
|
||||
## Technical Decisions
|
||||
|
||||
### Database
|
||||
- PostgreSQL with Ecto
|
||||
- Use binary_id (UUID) for all primary keys (already configured)
|
||||
- Indexes on foreign keys and frequently queried fields
|
||||
|
||||
### Background Jobs
|
||||
**Options:**
|
||||
1. Custom OTP solution with GenServer + Process.send_after
|
||||
2. Quantum scheduler
|
||||
3. Oban (more heavyweight but robust)
|
||||
|
||||
**Recommendation**: Start with custom OTP, migrate to Oban if needed
|
||||
|
||||
### Real-time Communication
|
||||
- Phoenix PubSub for server-side messaging
|
||||
- LiveView for UI updates
|
||||
- No WebSocket client code needed
|
||||
|
||||
### Ping Implementation
|
||||
**Options:**
|
||||
1. `:gen_icmp` library (requires raw sockets, might need permissions)
|
||||
2. System.cmd("ping") - simpler, cross-platform
|
||||
3. HTTP health checks (future enhancement)
|
||||
|
||||
**Recommendation**: System.cmd("ping") for MVP, abstract for future protocols
|
||||
|
||||
---
|
||||
|
||||
## Decisions Made
|
||||
|
||||
1. **Email notifications**: ✓ Yes - Users receive email alerts on status changes
|
||||
2. **Multi-org users**: ✓ Yes - Users can belong to multiple organizations
|
||||
3. **Invite system**: ✓ Email invitation system (secure token-based invites)
|
||||
4. **Check intervals**: ✓ Customizable per equipment (stored in equipment.check_interval_seconds)
|
||||
|
||||
## Additional Schema Needed
|
||||
|
||||
Based on decisions:
|
||||
- **OrganizationInvitations** table for email invite workflow
|
||||
- **email_sent_at** field in Alerts for tracking email delivery
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Alert escalation**: Any escalation policies (e.g., page admin if down > X minutes)?
|
||||
2. **Retention**: How long to keep monitoring_checks history?
|
||||
3. **Equipment types**: Just ping for now, or plan for SNMP, HTTP, etc.?
|
||||
4. **Site hierarchy depth**: Any limit on site nesting levels?
|
||||
|
||||
---
|
||||
|
||||
## Status: Planning Phase - Ready to Begin Stage 1
|
||||
Last updated: 2025-12-21
|
||||
|
|
@ -56,6 +56,19 @@ config :towerops, ToweropsWeb.Endpoint,
|
|||
pubsub_server: Towerops.PubSub,
|
||||
live_view: [signing_salt: "Uh1ABfdI"]
|
||||
|
||||
config :towerops, :scopes,
|
||||
user: [
|
||||
default: true,
|
||||
module: Towerops.Accounts.Scope,
|
||||
assign_key: :current_scope,
|
||||
access_path: [:user, :id],
|
||||
schema_key: :user_id,
|
||||
schema_type: :binary_id,
|
||||
schema_table: :users,
|
||||
test_data_fixture: Towerops.AccountsFixtures,
|
||||
test_setup_helper: :register_and_log_in_user
|
||||
]
|
||||
|
||||
# Import environment specific config. This must remain at the bottom
|
||||
# of this file so it overrides the configuration defined above.
|
||||
config :towerops,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import Config
|
||||
|
||||
# Only in tests, remove the complexity from the password hashing algorithm
|
||||
config :bcrypt_elixir, :log_rounds, 1
|
||||
|
||||
# Print only warnings and errors during test
|
||||
config :logger, level: :warning
|
||||
|
||||
|
|
|
|||
330
lib/towerops/accounts.ex
Normal file
330
lib/towerops/accounts.ex
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
defmodule Towerops.Accounts do
|
||||
@moduledoc """
|
||||
The Accounts context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
alias Towerops.Accounts.User
|
||||
alias Towerops.Accounts.UserNotifier
|
||||
alias Towerops.Accounts.UserToken
|
||||
alias Towerops.Repo
|
||||
|
||||
## Database getters
|
||||
|
||||
@doc """
|
||||
Gets a user by email.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_email("foo@example.com")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_email("unknown@example.com")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_email(email) when is_binary(email) do
|
||||
Repo.get_by(User, email: email)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a user by email and password.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
|
||||
%User{}
|
||||
|
||||
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do
|
||||
user = Repo.get_by(User, email: email)
|
||||
if User.valid_password?(user, password), do: user
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single user.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the User does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_user!(123)
|
||||
%User{}
|
||||
|
||||
iex> get_user!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_user!(id), do: Repo.get!(User, id)
|
||||
|
||||
## User registration
|
||||
|
||||
@doc """
|
||||
Registers a user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> register_user(%{field: value})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> register_user(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def register_user(attrs) do
|
||||
%User{}
|
||||
|> User.email_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers a user and creates a default organization.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> register_user_with_organization(%{field: value})
|
||||
{:ok, %User{}}
|
||||
|
||||
iex> register_user_with_organization(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def register_user_with_organization(attrs) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.insert(:user, fn _ ->
|
||||
User.email_changeset(%User{}, attrs)
|
||||
end)
|
||||
|> Ecto.Multi.run(:organization, fn _repo, %{user: user} ->
|
||||
org_name = attrs["organization_name"] || "#{user.email}'s Organization"
|
||||
|
||||
Towerops.Organizations.create_organization(
|
||||
%{name: org_name},
|
||||
user.id
|
||||
)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{user: user}} -> {:ok, user}
|
||||
{:error, :user, changeset, _} -> {:error, changeset}
|
||||
{:error, :organization, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
## Settings
|
||||
|
||||
@doc """
|
||||
Checks whether the user is in sudo mode.
|
||||
|
||||
The user is in sudo mode when the last authentication was done no further
|
||||
than 20 minutes ago. The limit can be given as second argument in minutes.
|
||||
"""
|
||||
def sudo_mode?(user, minutes \\ -20)
|
||||
|
||||
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
|
||||
DateTime.after?(ts, DateTime.add(DateTime.utc_now(), minutes, :minute))
|
||||
end
|
||||
|
||||
def sudo_mode?(_user, _minutes), do: false
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user email.
|
||||
|
||||
See `Towerops.Accounts.User.email_changeset/3` for a list of supported options.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_email(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_email(user, attrs \\ %{}, opts \\ []) do
|
||||
User.email_changeset(user, attrs, opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user email using the given token.
|
||||
|
||||
If the token matches, the user email is updated and the token is deleted.
|
||||
"""
|
||||
def update_user_email(user, token) do
|
||||
context = "change:#{user.email}"
|
||||
|
||||
Repo.transact(fn ->
|
||||
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
|
||||
%UserToken{sent_to: email} <- Repo.one(query),
|
||||
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
|
||||
{_count, _result} <-
|
||||
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
|
||||
{:ok, user}
|
||||
else
|
||||
_ -> {:error, :transaction_aborted}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for changing the user password.
|
||||
|
||||
See `Towerops.Accounts.User.password_changeset/3` for a list of supported options.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_user_password(user)
|
||||
%Ecto.Changeset{data: %User{}}
|
||||
|
||||
"""
|
||||
def change_user_password(user, attrs \\ %{}, opts \\ []) do
|
||||
User.password_changeset(user, attrs, opts)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the user password.
|
||||
|
||||
Returns a tuple with the updated user, as well as a list of expired tokens.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_user_password(user, %{password: ...})
|
||||
{:ok, {%User{}, [...]}}
|
||||
|
||||
iex> update_user_password(user, %{password: "too short"})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_user_password(user, attrs) do
|
||||
user
|
||||
|> User.password_changeset(attrs)
|
||||
|> update_user_and_delete_all_tokens()
|
||||
end
|
||||
|
||||
## Session
|
||||
|
||||
@doc """
|
||||
Generates a session token.
|
||||
"""
|
||||
def generate_user_session_token(user) do
|
||||
{token, user_token} = UserToken.build_session_token(user)
|
||||
Repo.insert!(user_token)
|
||||
token
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user with the given signed token.
|
||||
|
||||
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
|
||||
"""
|
||||
def get_user_by_session_token(token) do
|
||||
{:ok, query} = UserToken.verify_session_token_query(token)
|
||||
Repo.one(query)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the user with the given magic link token.
|
||||
"""
|
||||
def get_user_by_magic_link_token(token) do
|
||||
with {:ok, query} <- UserToken.verify_magic_link_token_query(token),
|
||||
{user, _token} <- Repo.one(query) do
|
||||
user
|
||||
else
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user in by magic link.
|
||||
|
||||
There are three cases to consider:
|
||||
|
||||
1. The user has already confirmed their email. They are logged in
|
||||
and the magic link is expired.
|
||||
|
||||
2. The user has not confirmed their email and no password is set.
|
||||
In this case, the user gets confirmed, logged in, and all tokens -
|
||||
including session ones - are expired. In theory, no other tokens
|
||||
exist but we delete all of them for best security practices.
|
||||
|
||||
3. The user has not confirmed their email but a password is set.
|
||||
This cannot happen in the default implementation but may be the
|
||||
source of security pitfalls. See the "Mixing magic link and password registration" section of
|
||||
`mix help phx.gen.auth`.
|
||||
"""
|
||||
def login_user_by_magic_link(token) do
|
||||
{:ok, query} = UserToken.verify_magic_link_token_query(token)
|
||||
|
||||
case Repo.one(query) do
|
||||
# Prevent session fixation attacks by disallowing magic links for unconfirmed users with password
|
||||
{%User{confirmed_at: nil, hashed_password: hash}, _token} when not is_nil(hash) ->
|
||||
raise """
|
||||
magic link log in is not allowed for unconfirmed users with a password set!
|
||||
|
||||
This cannot happen with the default implementation, which indicates that you
|
||||
might have adapted the code to a different use case. Please make sure to read the
|
||||
"Mixing magic link and password registration" section of `mix help phx.gen.auth`.
|
||||
"""
|
||||
|
||||
{%User{confirmed_at: nil} = user, _token} ->
|
||||
user
|
||||
|> User.confirm_changeset()
|
||||
|> update_user_and_delete_all_tokens()
|
||||
|
||||
{user, token} ->
|
||||
Repo.delete!(token)
|
||||
{:ok, {user, []}}
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@doc ~S"""
|
||||
Delivers the update email instructions to the given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
|
||||
{:ok, %{to: ..., body: ...}}
|
||||
|
||||
"""
|
||||
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
|
||||
when is_function(update_email_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
|
||||
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Delivers the magic link login instructions to the given user.
|
||||
"""
|
||||
def deliver_login_instructions(%User{} = user, magic_link_url_fun) when is_function(magic_link_url_fun, 1) do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "login")
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_login_instructions(user, magic_link_url_fun.(encoded_token))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes the signed token with the given context.
|
||||
"""
|
||||
def delete_user_session_token(token) do
|
||||
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
|
||||
:ok
|
||||
end
|
||||
|
||||
## Token helper
|
||||
|
||||
defp update_user_and_delete_all_tokens(changeset) do
|
||||
Repo.transact(fn ->
|
||||
with {:ok, user} <- Repo.update(changeset) do
|
||||
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
|
||||
|
||||
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
|
||||
|
||||
{:ok, {user, tokens_to_expire}}
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
33
lib/towerops/accounts/scope.ex
Normal file
33
lib/towerops/accounts/scope.ex
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
defmodule Towerops.Accounts.Scope do
|
||||
@moduledoc """
|
||||
Defines the scope of the caller to be used throughout the app.
|
||||
|
||||
The `Towerops.Accounts.Scope` allows public interfaces to receive
|
||||
information about the caller, such as if the call is initiated from an
|
||||
end-user, and if so, which user. Additionally, such a scope can carry fields
|
||||
such as "super user" or other privileges for use as authorization, or to
|
||||
ensure specific code paths can only be access for a given scope.
|
||||
|
||||
It is useful for logging as well as for scoping pubsub subscriptions and
|
||||
broadcasts when a caller subscribes to an interface or performs a particular
|
||||
action.
|
||||
|
||||
Feel free to extend the fields on this struct to fit the needs of
|
||||
growing application requirements.
|
||||
"""
|
||||
|
||||
alias Towerops.Accounts.User
|
||||
|
||||
defstruct user: nil
|
||||
|
||||
@doc """
|
||||
Creates a scope for the given user.
|
||||
|
||||
Returns nil if no user is given.
|
||||
"""
|
||||
def for_user(%User{} = user) do
|
||||
%__MODULE__{user: user}
|
||||
end
|
||||
|
||||
def for_user(nil), do: nil
|
||||
end
|
||||
137
lib/towerops/accounts/user.ex
Normal file
137
lib/towerops/accounts/user.ex
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
defmodule Towerops.Accounts.User do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "users" do
|
||||
field :email, :string
|
||||
field :password, :string, virtual: true, redact: true
|
||||
field :hashed_password, :string, redact: true
|
||||
field :confirmed_at, :utc_datetime
|
||||
field :authenticated_at, :utc_datetime, virtual: true
|
||||
|
||||
has_many :memberships, Towerops.Organizations.Membership
|
||||
has_many :organizations, through: [:memberships, :organization]
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for registering or changing the email.
|
||||
|
||||
It requires the email to change otherwise an error is added.
|
||||
|
||||
## Options
|
||||
|
||||
* `:validate_unique` - Set to false if you don't want to validate the
|
||||
uniqueness of the email, useful when displaying live validations.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
def email_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:email])
|
||||
|> validate_email(opts)
|
||||
end
|
||||
|
||||
defp validate_email(changeset, opts) do
|
||||
changeset =
|
||||
changeset
|
||||
|> validate_required([:email])
|
||||
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/, message: "must have the @ sign and no spaces")
|
||||
|> validate_length(:email, max: 160)
|
||||
|
||||
if Keyword.get(opts, :validate_unique, true) do
|
||||
changeset
|
||||
|> unsafe_validate_unique(:email, Towerops.Repo)
|
||||
|> unique_constraint(:email)
|
||||
|> validate_email_changed()
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_email_changed(changeset) do
|
||||
if get_field(changeset, :email) && get_change(changeset, :email) == nil do
|
||||
add_error(changeset, :email, "did not change")
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
A user changeset for changing the password.
|
||||
|
||||
It is important to validate the length of the password, as long passwords may
|
||||
be very expensive to hash for certain algorithms.
|
||||
|
||||
## Options
|
||||
|
||||
* `:hash_password` - Hashes the password so it can be stored securely
|
||||
in the database and ensures the password field is cleared to prevent
|
||||
leaks in the logs. If password hashing is not needed and clearing the
|
||||
password field is not desired (like when using this changeset for
|
||||
validations on a LiveView form), this option can be set to `false`.
|
||||
Defaults to `true`.
|
||||
"""
|
||||
def password_changeset(user, attrs, opts \\ []) do
|
||||
user
|
||||
|> cast(attrs, [:password])
|
||||
|> validate_confirmation(:password, message: "does not match password")
|
||||
|> validate_password(opts)
|
||||
end
|
||||
|
||||
defp validate_password(changeset, opts) do
|
||||
changeset
|
||||
|> validate_required([:password])
|
||||
|> validate_length(:password, min: 12, max: 72)
|
||||
# Examples of additional password validation:
|
||||
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
|
||||
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
|
||||
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|
||||
|> maybe_hash_password(opts)
|
||||
end
|
||||
|
||||
defp maybe_hash_password(changeset, opts) do
|
||||
hash_password? = Keyword.get(opts, :hash_password, true)
|
||||
password = get_change(changeset, :password)
|
||||
|
||||
if hash_password? && password && changeset.valid? do
|
||||
changeset
|
||||
# If using Bcrypt, then further validate it is at most 72 bytes long
|
||||
|> validate_length(:password, max: 72, count: :bytes)
|
||||
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
|
||||
# would keep the database transaction open longer and hurt performance.
|
||||
|> put_change(:hashed_password, Bcrypt.hash_pwd_salt(password))
|
||||
|> delete_change(:password)
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Confirms the account by setting `confirmed_at`.
|
||||
"""
|
||||
def confirm_changeset(user) do
|
||||
now = DateTime.utc_now(:second)
|
||||
change(user, confirmed_at: now)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies the password.
|
||||
|
||||
If there is no user or the user doesn't have a password, we call
|
||||
`Bcrypt.no_user_verify/0` to avoid timing attacks.
|
||||
"""
|
||||
def valid_password?(%Towerops.Accounts.User{hashed_password: hashed_password}, password)
|
||||
when is_binary(hashed_password) and byte_size(password) > 0 do
|
||||
Bcrypt.verify_pass(password, hashed_password)
|
||||
end
|
||||
|
||||
def valid_password?(_, _) do
|
||||
Bcrypt.no_user_verify()
|
||||
false
|
||||
end
|
||||
end
|
||||
85
lib/towerops/accounts/user_notifier.ex
Normal file
85
lib/towerops/accounts/user_notifier.ex
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
defmodule Towerops.Accounts.UserNotifier do
|
||||
@moduledoc false
|
||||
import Swoosh.Email
|
||||
|
||||
alias Towerops.Accounts.User
|
||||
alias Towerops.Mailer
|
||||
|
||||
# Delivers the email using the application mailer.
|
||||
defp deliver(recipient, subject, body) do
|
||||
email =
|
||||
new()
|
||||
|> to(recipient)
|
||||
|> from({"Towerops", "contact@example.com"})
|
||||
|> subject(subject)
|
||||
|> text_body(body)
|
||||
|
||||
with {:ok, _metadata} <- Mailer.deliver(email) do
|
||||
{:ok, email}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to update a user email.
|
||||
"""
|
||||
def deliver_update_email_instructions(user, url) do
|
||||
deliver(user.email, "Update email instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can change your email by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this change, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to log in with a magic link.
|
||||
"""
|
||||
def deliver_login_instructions(user, url) do
|
||||
case user do
|
||||
%User{confirmed_at: nil} -> deliver_confirmation_instructions(user, url)
|
||||
_ -> deliver_magic_link_instructions(user, url)
|
||||
end
|
||||
end
|
||||
|
||||
defp deliver_magic_link_instructions(user, url) do
|
||||
deliver(user.email, "Log in instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can log into your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't request this email, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
|
||||
defp deliver_confirmation_instructions(user, url) do
|
||||
deliver(user.email, "Confirmation instructions", """
|
||||
|
||||
==============================
|
||||
|
||||
Hi #{user.email},
|
||||
|
||||
You can confirm your account by visiting the URL below:
|
||||
|
||||
#{url}
|
||||
|
||||
If you didn't create an account with us, please ignore this.
|
||||
|
||||
==============================
|
||||
""")
|
||||
end
|
||||
end
|
||||
161
lib/towerops/accounts/user_token.ex
Normal file
161
lib/towerops/accounts/user_token.ex
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
defmodule Towerops.Accounts.UserToken do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Accounts.UserToken
|
||||
|
||||
@hash_algorithm :sha256
|
||||
@rand_size 32
|
||||
|
||||
# It is very important to keep the magic link token expiry short,
|
||||
# since someone with access to the email may take over the account.
|
||||
@magic_link_validity_in_minutes 15
|
||||
@change_email_validity_in_days 7
|
||||
@session_validity_in_days 14
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "users_tokens" do
|
||||
field :token, :binary
|
||||
field :context, :string
|
||||
field :sent_to, :string
|
||||
field :authenticated_at, :utc_datetime
|
||||
belongs_to :user, Towerops.Accounts.User
|
||||
|
||||
timestamps(type: :utc_datetime, updated_at: false)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a token that will be stored in a signed place,
|
||||
such as session or cookie. As they are signed, those
|
||||
tokens do not need to be hashed.
|
||||
|
||||
The reason why we store session tokens in the database, even
|
||||
though Phoenix already provides a session cookie, is because
|
||||
Phoenix's default session cookies are not persisted, they are
|
||||
simply signed and potentially encrypted. This means they are
|
||||
valid indefinitely, unless you change the signing/encryption
|
||||
salt.
|
||||
|
||||
Therefore, storing them allows individual user
|
||||
sessions to be expired. The token system can also be extended
|
||||
to store additional data, such as the device used for logging in.
|
||||
You could then use this information to display all valid sessions
|
||||
and devices in the UI and allow users to explicitly expire any
|
||||
session they deem invalid.
|
||||
"""
|
||||
def build_session_token(user) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
dt = user.authenticated_at || DateTime.utc_now(:second)
|
||||
{token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user found by the token, if any, along with the token's creation time.
|
||||
|
||||
The token is valid if it matches the value in the database and it has
|
||||
not expired (after @session_validity_in_days).
|
||||
"""
|
||||
def verify_session_token_query(token) do
|
||||
query =
|
||||
from token in by_token_and_context_query(token, "session"),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(@session_validity_in_days, "day"),
|
||||
select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
|
||||
|
||||
{:ok, query}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds a token and its hash to be delivered to the user's email.
|
||||
|
||||
The non-hashed token is sent to the user email while the
|
||||
hashed part is stored in the database. The original token cannot be reconstructed,
|
||||
which means anyone with read-only access to the database cannot directly use
|
||||
the token in the application to gain access. Furthermore, if the user changes
|
||||
their email in the system, the tokens sent to the previous email are no longer
|
||||
valid.
|
||||
|
||||
Users can easily adapt the existing code to provide other types of delivery methods,
|
||||
for example, by phone numbers.
|
||||
"""
|
||||
def build_email_token(user, context) do
|
||||
build_hashed_token(user, context, user.email)
|
||||
end
|
||||
|
||||
defp build_hashed_token(user, context, sent_to) do
|
||||
token = :crypto.strong_rand_bytes(@rand_size)
|
||||
hashed_token = :crypto.hash(@hash_algorithm, token)
|
||||
|
||||
{Base.url_encode64(token, padding: false),
|
||||
%UserToken{
|
||||
token: hashed_token,
|
||||
context: context,
|
||||
sent_to: sent_to,
|
||||
user_id: user.id
|
||||
}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
If found, the query returns a tuple of the form `{user, token}`.
|
||||
|
||||
The given token is valid if it matches its hashed counterpart in the
|
||||
database. This function also checks if the token is being used within
|
||||
15 minutes. The context of a magic link token is always "login".
|
||||
"""
|
||||
def verify_magic_link_token_query(token) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
|
||||
query =
|
||||
from token in by_token_and_context_query(hashed_token, "login"),
|
||||
join: user in assoc(token, :user),
|
||||
where: token.inserted_at > ago(^@magic_link_validity_in_minutes, "minute"),
|
||||
where: token.sent_to == user.email,
|
||||
select: {user, token}
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if the token is valid and returns its underlying lookup query.
|
||||
|
||||
The query returns the user_token found by the token, if any.
|
||||
|
||||
This is used to validate requests to change the user
|
||||
email.
|
||||
The given token is valid if it matches its hashed counterpart in the
|
||||
database and if it has not expired (after @change_email_validity_in_days).
|
||||
The context must always start with "change:".
|
||||
"""
|
||||
def verify_change_email_token_query(token, "change:" <> _ = context) do
|
||||
case Base.url_decode64(token, padding: false) do
|
||||
{:ok, decoded_token} ->
|
||||
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
|
||||
|
||||
query =
|
||||
from token in by_token_and_context_query(hashed_token, context),
|
||||
where: token.inserted_at > ago(@change_email_validity_in_days, "day")
|
||||
|
||||
{:ok, query}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp by_token_and_context_query(token, context) do
|
||||
from UserToken, where: [token: ^token, context: ^context]
|
||||
end
|
||||
end
|
||||
228
lib/towerops/organizations.ex
Normal file
228
lib/towerops/organizations.ex
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
defmodule Towerops.Organizations do
|
||||
@moduledoc """
|
||||
The Organizations context.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Organizations.Invitation
|
||||
alias Towerops.Organizations.Membership
|
||||
alias Towerops.Organizations.Organization
|
||||
alias Towerops.Organizations.Policy
|
||||
alias Towerops.Repo
|
||||
|
||||
## Organizations
|
||||
|
||||
@doc """
|
||||
Returns the list of organizations for a user.
|
||||
"""
|
||||
def list_user_organizations(user_id) do
|
||||
Repo.all(
|
||||
from(o in Organization,
|
||||
join: m in Membership,
|
||||
on: m.organization_id == o.id,
|
||||
where: m.user_id == ^user_id,
|
||||
order_by: [desc: m.inserted_at],
|
||||
preload: [memberships: m]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single organization by ID.
|
||||
"""
|
||||
def get_organization!(id), do: Repo.get!(Organization, id)
|
||||
|
||||
@doc """
|
||||
Gets a single organization by slug.
|
||||
"""
|
||||
def get_organization_by_slug!(slug) do
|
||||
Repo.get_by!(Organization, slug: slug)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates an organization and adds the creator as owner.
|
||||
"""
|
||||
def create_organization(attrs, user_id) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.insert(:organization, Organization.changeset(%Organization{}, attrs))
|
||||
|> Ecto.Multi.insert(:membership, fn %{organization: organization} ->
|
||||
Membership.changeset(%Membership{}, %{
|
||||
organization_id: organization.id,
|
||||
user_id: user_id,
|
||||
role: :owner
|
||||
})
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{organization: organization}} -> {:ok, organization}
|
||||
{:error, :organization, changeset, _} -> {:error, changeset}
|
||||
{:error, :membership, changeset, _} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an organization.
|
||||
"""
|
||||
def update_organization(%Organization{} = organization, attrs) do
|
||||
organization
|
||||
|> Organization.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes an organization.
|
||||
"""
|
||||
def delete_organization(%Organization{} = organization) do
|
||||
Repo.delete(organization)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking organization changes.
|
||||
"""
|
||||
def change_organization(%Organization{} = organization, attrs \\ %{}) do
|
||||
Organization.changeset(organization, attrs)
|
||||
end
|
||||
|
||||
## Memberships
|
||||
|
||||
@doc """
|
||||
Gets a user's membership for an organization.
|
||||
"""
|
||||
def get_membership(organization_id, user_id) do
|
||||
Repo.get_by(Membership, organization_id: organization_id, user_id: user_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a user's membership for an organization, raises if not found.
|
||||
"""
|
||||
def get_membership!(organization_id, user_id) do
|
||||
Repo.get_by!(Membership, organization_id: organization_id, user_id: user_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Lists all memberships for an organization.
|
||||
"""
|
||||
def list_organization_memberships(organization_id) do
|
||||
Repo.all(
|
||||
from(m in Membership,
|
||||
where: m.organization_id == ^organization_id,
|
||||
preload: [:user],
|
||||
order_by: [asc: m.inserted_at]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a membership (adds a user to an organization).
|
||||
"""
|
||||
def create_membership(attrs) do
|
||||
%Membership{}
|
||||
|> Membership.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a membership (changes a user's role).
|
||||
"""
|
||||
def update_membership(%Membership{} = membership, attrs) do
|
||||
membership
|
||||
|> Membership.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a membership (removes a user from an organization).
|
||||
"""
|
||||
def delete_membership(%Membership{} = membership) do
|
||||
Repo.delete(membership)
|
||||
end
|
||||
|
||||
## Invitations
|
||||
|
||||
@doc """
|
||||
Lists pending invitations for an organization.
|
||||
"""
|
||||
def list_pending_invitations(organization_id) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
Repo.all(
|
||||
from(i in Invitation,
|
||||
where: i.organization_id == ^organization_id,
|
||||
where: is_nil(i.accepted_at),
|
||||
where: i.expires_at > ^now,
|
||||
preload: [:invited_by],
|
||||
order_by: [desc: i.inserted_at]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates an invitation.
|
||||
"""
|
||||
def create_invitation(attrs) do
|
||||
%Invitation{}
|
||||
|> Invitation.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets an invitation by token.
|
||||
"""
|
||||
def get_invitation_by_token(token) do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
Repo.one(
|
||||
from(i in Invitation,
|
||||
where: i.token == ^token,
|
||||
where: is_nil(i.accepted_at),
|
||||
where: i.expires_at > ^now,
|
||||
preload: [:organization]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Accepts an invitation and creates a membership.
|
||||
"""
|
||||
def accept_invitation(invitation, user_id) do
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.update(:invitation, fn _ ->
|
||||
Ecto.Changeset.change(invitation, %{accepted_at: DateTime.utc_now(), accepted_by_id: user_id})
|
||||
end)
|
||||
|> Ecto.Multi.insert(:membership, fn _ ->
|
||||
Membership.changeset(%Membership{}, %{
|
||||
organization_id: invitation.organization_id,
|
||||
user_id: user_id,
|
||||
role: invitation.role
|
||||
})
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{membership: membership}} -> {:ok, membership}
|
||||
{:error, _failed_operation, changeset, _changes_so_far} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes an invitation.
|
||||
"""
|
||||
def delete_invitation(%Invitation{} = invitation) do
|
||||
Repo.delete(invitation)
|
||||
end
|
||||
|
||||
## Authorization
|
||||
|
||||
@doc """
|
||||
Checks if a membership has permission to perform an action on a resource.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> can?(membership, :edit, :equipment)
|
||||
true
|
||||
|
||||
iex> can?(membership, :delete, :organization)
|
||||
false
|
||||
"""
|
||||
defdelegate can?(membership, action, resource), to: Policy
|
||||
end
|
||||
48
lib/towerops/organizations/invitation.ex
Normal file
48
lib/towerops/organizations/invitation.ex
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
defmodule Towerops.Organizations.Invitation do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Towerops.Accounts.User
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "organization_invitations" do
|
||||
field :email, :string
|
||||
field :role, Ecto.Enum, values: [:admin, :member, :viewer]
|
||||
field :token, :string
|
||||
field :accepted_at, :utc_datetime
|
||||
field :expires_at, :utc_datetime
|
||||
|
||||
belongs_to :organization, Towerops.Organizations.Organization
|
||||
belongs_to :invited_by, User
|
||||
belongs_to :accepted_by, User
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(invitation, attrs) do
|
||||
invitation
|
||||
|> cast(attrs, [:email, :role, :organization_id, :invited_by_id])
|
||||
|> validate_required([:email, :role, :organization_id, :invited_by_id])
|
||||
|> validate_format(:email, ~r/^[^\s]+@[^\s]+\.[^\s]+$/)
|
||||
|> generate_token()
|
||||
|> set_expires_at()
|
||||
|> validate_required([:token, :expires_at])
|
||||
|> unique_constraint(:token)
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
|> foreign_key_constraint(:invited_by_id)
|
||||
end
|
||||
|
||||
defp generate_token(changeset) do
|
||||
token = 32 |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false)
|
||||
put_change(changeset, :token, token)
|
||||
end
|
||||
|
||||
defp set_expires_at(changeset) do
|
||||
expires_at = DateTime.add(DateTime.utc_now(), 7, :day)
|
||||
put_change(changeset, :expires_at, expires_at)
|
||||
end
|
||||
end
|
||||
29
lib/towerops/organizations/membership.ex
Normal file
29
lib/towerops/organizations/membership.ex
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
defmodule Towerops.Organizations.Membership do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "organization_memberships" do
|
||||
field :role, Ecto.Enum, values: [:owner, :admin, :member, :viewer]
|
||||
|
||||
belongs_to :organization, Towerops.Organizations.Organization
|
||||
belongs_to :user, Towerops.Accounts.User
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(membership, attrs) do
|
||||
membership
|
||||
|> cast(attrs, [:role, :organization_id, :user_id])
|
||||
|> validate_required([:role, :organization_id, :user_id])
|
||||
|> foreign_key_constraint(:organization_id)
|
||||
|> foreign_key_constraint(:user_id)
|
||||
|> unique_constraint([:organization_id, :user_id],
|
||||
name: :organization_memberships_organization_id_user_id_index
|
||||
)
|
||||
end
|
||||
end
|
||||
50
lib/towerops/organizations/organization.ex
Normal file
50
lib/towerops/organizations/organization.ex
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
defmodule Towerops.Organizations.Organization do
|
||||
@moduledoc false
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :binary_id, autogenerate: true}
|
||||
@foreign_key_type :binary_id
|
||||
schema "organizations" do
|
||||
field :name, :string
|
||||
field :slug, :string
|
||||
|
||||
has_many :memberships, Towerops.Organizations.Membership
|
||||
has_many :users, through: [:memberships, :user]
|
||||
has_many :invitations, Towerops.Organizations.Invitation
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(organization, attrs) do
|
||||
organization
|
||||
|> cast(attrs, [:name])
|
||||
|> validate_required([:name])
|
||||
|> validate_length(:name, min: 2, max: 100)
|
||||
|> generate_slug()
|
||||
|> validate_required([:slug])
|
||||
|> unique_constraint(:slug)
|
||||
end
|
||||
|
||||
defp generate_slug(changeset) do
|
||||
case get_change(changeset, :name) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
name ->
|
||||
slug =
|
||||
name
|
||||
|> String.downcase()
|
||||
|> String.replace(~r/[^a-z0-9\s-]/, "")
|
||||
|> String.replace(~r/\s+/, "-")
|
||||
|> String.replace(~r/-+/, "-")
|
||||
|> String.trim("-")
|
||||
|
||||
# Add random suffix to ensure uniqueness
|
||||
slug_with_suffix = "#{slug}-#{:rand.uniform(9999)}"
|
||||
put_change(changeset, :slug, slug_with_suffix)
|
||||
end
|
||||
end
|
||||
end
|
||||
46
lib/towerops/organizations/policy.ex
Normal file
46
lib/towerops/organizations/policy.ex
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
defmodule Towerops.Organizations.Policy do
|
||||
@moduledoc """
|
||||
Authorization policy for organization resources.
|
||||
"""
|
||||
|
||||
alias Towerops.Organizations.Membership
|
||||
|
||||
@doc """
|
||||
Checks if a user can perform an action on a resource within an organization.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> can?(%Membership{role: :owner}, :delete, :organization)
|
||||
true
|
||||
|
||||
iex> can?(%Membership{role: :viewer}, :edit, :equipment)
|
||||
false
|
||||
"""
|
||||
def can?(%Membership{role: role}, action, resource) do
|
||||
check_permission(role, action, resource)
|
||||
end
|
||||
|
||||
def can?(nil, _action, _resource), do: false
|
||||
|
||||
# Owner permissions - can do everything
|
||||
defp check_permission(:owner, _action, _resource), do: true
|
||||
|
||||
# Admin permissions
|
||||
defp check_permission(:admin, :delete, :organization), do: false
|
||||
defp check_permission(:admin, _action, _resource), do: true
|
||||
|
||||
# Member permissions
|
||||
defp check_permission(:member, action, :organization) when action in [:view, :list], do: true
|
||||
|
||||
defp check_permission(:member, :delete, _resource), do: false
|
||||
defp check_permission(:member, action, :membership) when action in [:create, :edit], do: false
|
||||
defp check_permission(:member, action, :invitation) when action in [:create, :delete], do: false
|
||||
|
||||
defp check_permission(:member, action, resource)
|
||||
when action in [:view, :list, :create, :edit] and resource in [:site, :equipment, :alert], do: true
|
||||
|
||||
# Viewer permissions - read-only
|
||||
defp check_permission(:viewer, action, _resource) when action in [:view, :list], do: true
|
||||
defp check_permission(:viewer, :acknowledge, :alert), do: true
|
||||
defp check_permission(:viewer, _action, _resource), do: false
|
||||
end
|
||||
|
|
@ -31,6 +31,26 @@
|
|||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<ul class="menu menu-horizontal w-full relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
|
||||
<%= if @current_scope do %>
|
||||
<li>
|
||||
{@current_scope.user.email}
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/users/settings"}>Settings</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/users/log-out"} method="delete">Log out</.link>
|
||||
</li>
|
||||
<% else %>
|
||||
<li>
|
||||
<.link href={~p"/users/register"}>Register</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/users/log-in"}>Log in</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
{@inner_content}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
32
lib/towerops_web/controllers/user_registration_controller.ex
Normal file
32
lib/towerops_web/controllers/user_registration_controller.ex
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
defmodule ToweropsWeb.UserRegistrationController do
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias Towerops.Accounts.User
|
||||
|
||||
def new(conn, _params) do
|
||||
changeset = Accounts.change_user_email(%User{})
|
||||
render(conn, :new, changeset: changeset)
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => user_params}) do
|
||||
case Accounts.register_user_with_organization(user_params) do
|
||||
{:ok, user} ->
|
||||
{:ok, _} =
|
||||
Accounts.deliver_login_instructions(
|
||||
user,
|
||||
&url(~p"/users/log-in/#{&1}")
|
||||
)
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"An email was sent to #{user.email}, please access it to confirm your account."
|
||||
)
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
render(conn, :new, changeset: changeset)
|
||||
end
|
||||
end
|
||||
end
|
||||
5
lib/towerops_web/controllers/user_registration_html.ex
Normal file
5
lib/towerops_web/controllers/user_registration_html.ex
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
defmodule ToweropsWeb.UserRegistrationHTML do
|
||||
use ToweropsWeb, :html
|
||||
|
||||
embed_templates "user_registration_html/*"
|
||||
end
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Register for an account
|
||||
<:subtitle>
|
||||
Already registered?
|
||||
<.link navigate={~p"/users/log-in"} class="font-semibold text-brand hover:underline">
|
||||
Log in
|
||||
</.link>
|
||||
to your account now.
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<.form :let={f} for={@changeset} action={~p"/users/register"}>
|
||||
<.input
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
|
||||
<.button phx-disable-with="Creating account..." class="btn btn-primary w-full">
|
||||
Create an account
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
88
lib/towerops_web/controllers/user_session_controller.ex
Normal file
88
lib/towerops_web/controllers/user_session_controller.ex
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
defmodule ToweropsWeb.UserSessionController do
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias ToweropsWeb.UserAuth
|
||||
|
||||
def new(conn, _params) do
|
||||
email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
|
||||
form = Phoenix.Component.to_form(%{"email" => email}, as: "user")
|
||||
|
||||
render(conn, :new, form: form)
|
||||
end
|
||||
|
||||
# magic link login
|
||||
def create(conn, %{"user" => %{"token" => token} = user_params} = params) do
|
||||
info =
|
||||
case params do
|
||||
%{"_action" => "confirmed"} -> "User confirmed successfully."
|
||||
_ -> "Welcome back!"
|
||||
end
|
||||
|
||||
case Accounts.login_user_by_magic_link(token) do
|
||||
{:ok, {user, _expired_tokens}} ->
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
|
||||
{:error, :not_found} ->
|
||||
conn
|
||||
|> put_flash(:error, "The link is invalid or it has expired.")
|
||||
|> render(:new, form: Phoenix.Component.to_form(%{}, as: "user"))
|
||||
end
|
||||
end
|
||||
|
||||
# email + password login
|
||||
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
|
||||
if user = Accounts.get_user_by_email_and_password(email, password) do
|
||||
conn
|
||||
|> put_flash(:info, "Welcome back!")
|
||||
|> UserAuth.log_in_user(user, user_params)
|
||||
else
|
||||
form = Phoenix.Component.to_form(user_params, as: "user")
|
||||
|
||||
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
|
||||
conn
|
||||
|> put_flash(:error, "Invalid email or password")
|
||||
|> render(:new, form: form)
|
||||
end
|
||||
end
|
||||
|
||||
# magic link request
|
||||
def create(conn, %{"user" => %{"email" => email}}) do
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
Accounts.deliver_login_instructions(
|
||||
user,
|
||||
&url(~p"/users/log-in/#{&1}")
|
||||
)
|
||||
end
|
||||
|
||||
info =
|
||||
"If your email is in our system, you will receive instructions for logging in shortly."
|
||||
|
||||
conn
|
||||
|> put_flash(:info, info)
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
|
||||
def confirm(conn, %{"token" => token}) do
|
||||
if user = Accounts.get_user_by_magic_link_token(token) do
|
||||
form = Phoenix.Component.to_form(%{"token" => token}, as: "user")
|
||||
|
||||
conn
|
||||
|> assign(:user, user)
|
||||
|> assign(:form, form)
|
||||
|> render(:confirm)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "Magic link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
end
|
||||
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|> UserAuth.log_out_user()
|
||||
end
|
||||
end
|
||||
9
lib/towerops_web/controllers/user_session_html.ex
Normal file
9
lib/towerops_web/controllers/user_session_html.ex
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
defmodule ToweropsWeb.UserSessionHTML do
|
||||
use ToweropsWeb, :html
|
||||
|
||||
embed_templates "user_session_html/*"
|
||||
|
||||
defp local_mail_adapter? do
|
||||
Application.get_env(:towerops, Towerops.Mailer)[:adapter] == Swoosh.Adapters.Local
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm">
|
||||
<div class="text-center">
|
||||
<.header>Welcome {@user.email}</.header>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
:if={!@user.confirmed_at}
|
||||
for={@form}
|
||||
id="confirmation_form"
|
||||
action={~p"/users/log-in?_action=confirmed"}
|
||||
phx-mounted={JS.focus_first()}
|
||||
>
|
||||
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
||||
<.button
|
||||
name={@form[:remember_me].name}
|
||||
value="true"
|
||||
phx-disable-with="Confirming..."
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
Confirm and stay logged in
|
||||
</.button>
|
||||
<.button phx-disable-with="Confirming..." class="btn btn-primary btn-soft w-full mt-2">
|
||||
Confirm and log in only this time
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<.form
|
||||
:if={@user.confirmed_at}
|
||||
for={@form}
|
||||
id="login_form"
|
||||
action={~p"/users/log-in"}
|
||||
phx-mounted={JS.focus_first()}
|
||||
>
|
||||
<input type="hidden" name={@form[:token].name} value={@form[:token].value} />
|
||||
<%= if @current_scope do %>
|
||||
<.button variant="primary" phx-disable-with="Logging in..." class="btn btn-primary w-full">
|
||||
Log in
|
||||
</.button>
|
||||
<% else %>
|
||||
<.button
|
||||
name={@form[:remember_me].name}
|
||||
value="true"
|
||||
phx-disable-with="Logging in..."
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
Keep me logged in on this device
|
||||
</.button>
|
||||
<.button phx-disable-with="Logging in..." class="btn btn-primary btn-soft w-full mt-2">
|
||||
Log me in only this time
|
||||
</.button>
|
||||
<% end %>
|
||||
</.form>
|
||||
|
||||
<p :if={!@user.confirmed_at} class="alert alert-outline mt-8">
|
||||
Tip: If you prefer passwords, you can enable them in the user settings.
|
||||
</p>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
70
lib/towerops_web/controllers/user_session_html/new.html.heex
Normal file
70
lib/towerops_web/controllers/user_session_html/new.html.heex
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="mx-auto max-w-sm space-y-4">
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
<p>Log in</p>
|
||||
<:subtitle>
|
||||
<%= if @current_scope do %>
|
||||
You need to reauthenticate to perform sensitive actions on your account.
|
||||
<% else %>
|
||||
Don't have an account? <.link
|
||||
navigate={~p"/users/register"}
|
||||
class="font-semibold text-brand hover:underline"
|
||||
phx-no-format
|
||||
>Sign up</.link> for an account now.
|
||||
<% end %>
|
||||
</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<div :if={local_mail_adapter?()} class="alert alert-info">
|
||||
<.icon name="hero-information-circle" class="size-6 shrink-0" />
|
||||
<div>
|
||||
<p>You are running the local mail adapter.</p>
|
||||
<p>
|
||||
To see sent emails, visit <.link href="/dev/mailbox" class="underline">the mailbox page</.link>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.form :let={f} for={@form} as={:user} id="login_form_magic" action={~p"/users/log-in"}>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
phx-mounted={JS.focus()}
|
||||
/>
|
||||
<.button class="btn btn-primary w-full">
|
||||
Log in with email <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<div class="divider">or</div>
|
||||
|
||||
<.form :let={f} for={@form} as={:user} id="login_form_password" action={~p"/users/log-in"}>
|
||||
<.input
|
||||
readonly={!!@current_scope}
|
||||
field={f[:email]}
|
||||
type="email"
|
||||
label="Email"
|
||||
autocomplete="email"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={f[:password]}
|
||||
type="password"
|
||||
label="Password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<.button class="btn btn-primary w-full" name={@form[:remember_me].name} value="true">
|
||||
Log in and stay logged in <span aria-hidden="true">→</span>
|
||||
</.button>
|
||||
<.button class="btn btn-primary btn-soft w-full mt-2">
|
||||
Log in only this time
|
||||
</.button>
|
||||
</.form>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
77
lib/towerops_web/controllers/user_settings_controller.ex
Normal file
77
lib/towerops_web/controllers/user_settings_controller.ex
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
defmodule ToweropsWeb.UserSettingsController do
|
||||
use ToweropsWeb, :controller
|
||||
|
||||
import ToweropsWeb.UserAuth, only: [require_sudo_mode: 2]
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias ToweropsWeb.UserAuth
|
||||
|
||||
plug :require_sudo_mode
|
||||
plug :assign_email_and_password_changesets
|
||||
|
||||
def edit(conn, _params) do
|
||||
render(conn, :edit)
|
||||
end
|
||||
|
||||
def update(conn, %{"action" => "update_email"} = params) do
|
||||
%{"user" => user_params} = params
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
case Accounts.change_user_email(user, user_params) do
|
||||
%{valid?: true} = changeset ->
|
||||
Accounts.deliver_user_update_email_instructions(
|
||||
Ecto.Changeset.apply_action!(changeset, :insert),
|
||||
user.email,
|
||||
&url(~p"/users/settings/confirm-email/#{&1}")
|
||||
)
|
||||
|
||||
conn
|
||||
|> put_flash(
|
||||
:info,
|
||||
"A link to confirm your email change has been sent to the new address."
|
||||
)
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
changeset ->
|
||||
render(conn, :edit, email_changeset: %{changeset | action: :insert})
|
||||
end
|
||||
end
|
||||
|
||||
def update(conn, %{"action" => "update_password"} = params) do
|
||||
%{"user" => user_params} = params
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
case Accounts.update_user_password(user, user_params) do
|
||||
{:ok, {user, _}} ->
|
||||
conn
|
||||
|> put_flash(:info, "Password updated successfully.")
|
||||
|> put_session(:user_return_to, ~p"/users/settings")
|
||||
|> UserAuth.log_in_user(user)
|
||||
|
||||
{:error, changeset} ->
|
||||
render(conn, :edit, password_changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
def confirm_email(conn, %{"token" => token}) do
|
||||
case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
|
||||
{:ok, _user} ->
|
||||
conn
|
||||
|> put_flash(:info, "Email changed successfully.")
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
|
||||
{:error, _} ->
|
||||
conn
|
||||
|> put_flash(:error, "Email change link is invalid or it has expired.")
|
||||
|> redirect(to: ~p"/users/settings")
|
||||
end
|
||||
end
|
||||
|
||||
defp assign_email_and_password_changesets(conn, _opts) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
conn
|
||||
|> assign(:email_changeset, Accounts.change_user_email(user))
|
||||
|> assign(:password_changeset, Accounts.change_user_password(user))
|
||||
end
|
||||
end
|
||||
5
lib/towerops_web/controllers/user_settings_html.ex
Normal file
5
lib/towerops_web/controllers/user_settings_html.ex
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
defmodule ToweropsWeb.UserSettingsHTML do
|
||||
use ToweropsWeb, :html
|
||||
|
||||
embed_templates "user_settings_html/*"
|
||||
end
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
<Layouts.app flash={@flash} current_scope={@current_scope}>
|
||||
<div class="text-center">
|
||||
<.header>
|
||||
Account Settings
|
||||
<:subtitle>Manage your account email address and password settings</:subtitle>
|
||||
</.header>
|
||||
</div>
|
||||
|
||||
<.form :let={f} for={@email_changeset} action={~p"/users/settings"} id="update_email">
|
||||
<input type="hidden" name="action" value="update_email" />
|
||||
|
||||
<.input field={f[:email]} type="email" label="Email" autocomplete="email" required />
|
||||
|
||||
<.button variant="primary" phx-disable-with="Changing...">Change Email</.button>
|
||||
</.form>
|
||||
|
||||
<div class="divider" />
|
||||
|
||||
<.form :let={f} for={@password_changeset} action={~p"/users/settings"} id="update_password">
|
||||
<input type="hidden" name="action" value="update_password" />
|
||||
|
||||
<.input
|
||||
field={f[:password]}
|
||||
type="password"
|
||||
label="New password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<.input
|
||||
field={f[:password_confirmation]}
|
||||
type="password"
|
||||
label="Confirm new password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<.button variant="primary" phx-disable-with="Changing...">
|
||||
Save Password
|
||||
</.button>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
11
lib/towerops_web/live/dashboard_live.ex
Normal file
11
lib/towerops_web/live/dashboard_live.ex
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
defmodule ToweropsWeb.DashboardLive do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_organization
|
||||
|
||||
{:ok, assign(socket, :page_title, organization.name)}
|
||||
end
|
||||
end
|
||||
45
lib/towerops_web/live/dashboard_live.html.heex
Normal file
45
lib/towerops_web/live/dashboard_live.html.heex
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<.header>
|
||||
Dashboard
|
||||
<:subtitle>Welcome to {@current_organization.name}</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-base">Sites</h3>
|
||||
<p class="text-3xl font-bold">0</p>
|
||||
<p class="text-sm text-base-content/60">Total sites</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-base">Equipment</h3>
|
||||
<p class="text-3xl font-bold">0</p>
|
||||
<p class="text-sm text-base-content/60">Total equipment</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-base">Active Alerts</h3>
|
||||
<p class="text-3xl font-bold text-error">0</p>
|
||||
<p class="text-sm text-base-content/60">Unacknowledged alerts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="text-xl font-semibold mb-4">Quick Actions</h2>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<.link navigate={~p"/orgs/#{@current_organization.slug}/sites"} class="btn btn-primary">
|
||||
<.icon name="hero-building-office" class="w-5 h-5" /> Manage Sites
|
||||
</.link>
|
||||
<.link navigate={~p"/orgs/#{@current_organization.slug}/equipment"} class="btn btn-primary">
|
||||
<.icon name="hero-server" class="w-5 h-5" /> Manage Equipment
|
||||
</.link>
|
||||
<.link navigate={~p"/orgs/#{@current_organization.slug}/settings"} class="btn">
|
||||
<.icon name="hero-cog-6-tooth" class="w-5 h-5" /> Settings
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
22
lib/towerops_web/live/org_live/index.ex
Normal file
22
lib/towerops_web/live/org_live/index.ex
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
defmodule ToweropsWeb.OrgLive.Index do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Organizations
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
organizations = Organizations.list_user_organizations(user.id)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Your Organizations")
|
||||
|> assign(:organizations, organizations)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
39
lib/towerops_web/live/org_live/index.html.heex
Normal file
39
lib/towerops_web/live/org_live/index.html.heex
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<.header>
|
||||
{@page_title}
|
||||
<:actions>
|
||||
<.link navigate={~p"/orgs/new"} class="btn btn-primary">
|
||||
<.icon name="hero-plus" class="w-5 h-5" /> New Organization
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="mt-8 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div :for={org <- @organizations} class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{org.name}</h2>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{Enum.find(org.memberships, &(&1.user_id == @current_scope.user.id)).role
|
||||
|> to_string()
|
||||
|> String.capitalize()}
|
||||
</p>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<.link navigate={~p"/orgs/#{org.slug}"} class="btn btn-primary">
|
||||
Open
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @organizations == [] do %>
|
||||
<div class="text-center py-12">
|
||||
<.icon name="hero-building-office" class="mx-auto w-12 h-12 text-base-content/40" />
|
||||
<h3 class="mt-2 text-sm font-semibold text-base-content">No organizations</h3>
|
||||
<p class="mt-1 text-sm text-base-content/60">Get started by creating a new organization.</p>
|
||||
<div class="mt-6">
|
||||
<.link navigate={~p"/orgs/new"} class="btn btn-primary">
|
||||
<.icon name="hero-plus" class="w-5 h-5" /> New Organization
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
43
lib/towerops_web/live/org_live/new.ex
Normal file
43
lib/towerops_web/live/org_live/new.ex
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
defmodule ToweropsWeb.OrgLive.New do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Organizations
|
||||
alias Towerops.Organizations.Organization
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
changeset = Organizations.change_organization(%Organization{})
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "New Organization")
|
||||
|> assign(:form, to_form(changeset))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"organization" => org_params}, socket) do
|
||||
changeset =
|
||||
%Organization{}
|
||||
|> Organizations.change_organization(org_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"organization" => org_params}, socket) do
|
||||
user = socket.assigns.current_scope.user
|
||||
|
||||
case Organizations.create_organization(org_params, user.id) do
|
||||
{:ok, organization} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Organization created successfully")
|
||||
|> push_navigate(to: ~p"/orgs/#{organization.slug}")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
end
|
||||
end
|
||||
end
|
||||
17
lib/towerops_web/live/org_live/new.html.heex
Normal file
17
lib/towerops_web/live/org_live/new.html.heex
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>Create a new organization to manage your sites and equipment</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="max-w-2xl mt-8">
|
||||
<.form for={@form} id="organization-form" phx-change="validate" phx-submit="save">
|
||||
<div class="space-y-4">
|
||||
<.input field={@form[:name]} type="text" label="Organization Name" required />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<.button phx-disable-with="Creating...">Create Organization</.button>
|
||||
<.link navigate={~p"/orgs"} class="btn">Cancel</.link>
|
||||
</div>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
defmodule ToweropsWeb.Router do
|
||||
use ToweropsWeb, :router
|
||||
|
||||
import ToweropsWeb.UserAuth
|
||||
|
||||
pipeline :browser do
|
||||
plug :accepts, ["html"]
|
||||
plug :fetch_session
|
||||
|
|
@ -8,6 +10,7 @@ defmodule ToweropsWeb.Router do
|
|||
plug :put_root_layout, html: {ToweropsWeb.Layouts, :root}
|
||||
plug :protect_from_forgery
|
||||
plug :put_secure_browser_headers
|
||||
plug :fetch_current_scope_for_user
|
||||
end
|
||||
|
||||
pipeline :api do
|
||||
|
|
@ -41,4 +44,45 @@ defmodule ToweropsWeb.Router do
|
|||
forward "/mailbox", Plug.Swoosh.MailboxPreview
|
||||
end
|
||||
end
|
||||
|
||||
## Authentication routes
|
||||
|
||||
scope "/", ToweropsWeb do
|
||||
pipe_through [:browser, :redirect_if_user_is_authenticated]
|
||||
|
||||
get "/users/register", UserRegistrationController, :new
|
||||
post "/users/register", UserRegistrationController, :create
|
||||
end
|
||||
|
||||
scope "/", ToweropsWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
get "/users/settings", UserSettingsController, :edit
|
||||
put "/users/settings", UserSettingsController, :update
|
||||
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
|
||||
end
|
||||
|
||||
scope "/", ToweropsWeb do
|
||||
pipe_through [:browser]
|
||||
|
||||
get "/users/log-in", UserSessionController, :new
|
||||
get "/users/log-in/:token", UserSessionController, :confirm
|
||||
post "/users/log-in", UserSessionController, :create
|
||||
delete "/users/log-out", UserSessionController, :delete
|
||||
end
|
||||
|
||||
## Organization routes
|
||||
|
||||
scope "/", ToweropsWeb do
|
||||
pipe_through [:browser, :require_authenticated_user]
|
||||
|
||||
live "/orgs", OrgLive.Index, :index
|
||||
live "/orgs/new", OrgLive.New, :new
|
||||
end
|
||||
|
||||
scope "/orgs/:org_slug", ToweropsWeb do
|
||||
pipe_through [:browser, :require_authenticated_user, :load_current_organization]
|
||||
|
||||
live "/", DashboardLive, :index
|
||||
end
|
||||
end
|
||||
|
|
|
|||
255
lib/towerops_web/user_auth.ex
Normal file
255
lib/towerops_web/user_auth.ex
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
defmodule ToweropsWeb.UserAuth do
|
||||
@moduledoc false
|
||||
use ToweropsWeb, :verified_routes
|
||||
|
||||
import Phoenix.Controller
|
||||
import Plug.Conn
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias Towerops.Accounts.Scope
|
||||
|
||||
# Make the remember me cookie valid for 14 days. This should match
|
||||
# the session validity setting in UserToken.
|
||||
@max_cookie_age_in_days 14
|
||||
@remember_me_cookie "_towerops_web_user_remember_me"
|
||||
@remember_me_options [
|
||||
sign: true,
|
||||
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
|
||||
same_site: "Lax"
|
||||
]
|
||||
|
||||
# How old the session token should be before a new one is issued. When a request is made
|
||||
# with a session token older than this value, then a new session token will be created
|
||||
# and the session and remember-me cookies (if set) will be updated with the new token.
|
||||
# Lowering this value will result in more tokens being created by active users. Increasing
|
||||
# it will result in less time before a session token expires for a user to get issued a new
|
||||
# token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
|
||||
# the reissuing of tokens completely.
|
||||
@session_reissue_age_in_days 7
|
||||
|
||||
@doc """
|
||||
Logs the user in.
|
||||
|
||||
Redirects to the session's `:user_return_to` path
|
||||
or falls back to the `signed_in_path/1`.
|
||||
"""
|
||||
def log_in_user(conn, user, params \\ %{}) do
|
||||
user_return_to = get_session(conn, :user_return_to)
|
||||
|
||||
conn
|
||||
|> create_or_extend_session(user, params)
|
||||
|> redirect(to: user_return_to || signed_in_path(conn))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the user out.
|
||||
|
||||
It clears all session data for safety. See renew_session.
|
||||
"""
|
||||
def log_out_user(conn) do
|
||||
user_token = get_session(conn, :user_token)
|
||||
user_token && Accounts.delete_user_session_token(user_token)
|
||||
|
||||
if live_socket_id = get_session(conn, :live_socket_id) do
|
||||
ToweropsWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
|
||||
end
|
||||
|
||||
conn
|
||||
|> renew_session(nil)
|
||||
|> delete_resp_cookie(@remember_me_cookie)
|
||||
|> redirect(to: ~p"/")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authenticates the user by looking into the session and remember me token.
|
||||
|
||||
Will reissue the session token if it is older than the configured age.
|
||||
"""
|
||||
def fetch_current_scope_for_user(conn, _opts) do
|
||||
with {token, conn} <- ensure_user_token(conn),
|
||||
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_user(user))
|
||||
|> maybe_reissue_user_session_token(user, token_inserted_at)
|
||||
else
|
||||
nil -> assign(conn, :current_scope, Scope.for_user(nil))
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_user_token(conn) do
|
||||
if token = get_session(conn, :user_token) do
|
||||
{token, conn}
|
||||
else
|
||||
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
|
||||
|
||||
if token = conn.cookies[@remember_me_cookie] do
|
||||
{token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Reissue the session token if it is older than the configured reissue age.
|
||||
defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
|
||||
token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)
|
||||
|
||||
if token_age >= @session_reissue_age_in_days do
|
||||
create_or_extend_session(conn, user, %{})
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
# This function is the one responsible for creating session tokens
|
||||
# and storing them safely in the session and cookies. It may be called
|
||||
# either when logging in, during sudo mode, or to renew a session which
|
||||
# will soon expire.
|
||||
#
|
||||
# When the session is created, rather than extended, the renew_session
|
||||
# function will clear the session to avoid fixation attacks. See the
|
||||
# renew_session function to customize this behaviour.
|
||||
defp create_or_extend_session(conn, user, params) do
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
remember_me = get_session(conn, :user_remember_me)
|
||||
|
||||
conn
|
||||
|> renew_session(user)
|
||||
|> put_token_in_session(token)
|
||||
|> maybe_write_remember_me_cookie(token, params, remember_me)
|
||||
end
|
||||
|
||||
# Do not renew session if the user is already logged in
|
||||
# to prevent CSRF errors or data being lost in tabs that are still open
|
||||
defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do
|
||||
conn
|
||||
end
|
||||
|
||||
# This function renews the session ID and erases the whole
|
||||
# session to avoid fixation attacks. If there is any data
|
||||
# in the session you may want to preserve after log in/log out,
|
||||
# you must explicitly fetch the session data before clearing
|
||||
# and then immediately set it after clearing, for example:
|
||||
#
|
||||
# defp renew_session(conn, _user) do
|
||||
# delete_csrf_token()
|
||||
# preferred_locale = get_session(conn, :preferred_locale)
|
||||
#
|
||||
# conn
|
||||
# |> configure_session(renew: true)
|
||||
# |> clear_session()
|
||||
# |> put_session(:preferred_locale, preferred_locale)
|
||||
# end
|
||||
#
|
||||
defp renew_session(conn, _user) do
|
||||
delete_csrf_token()
|
||||
|
||||
conn
|
||||
|> configure_session(renew: true)
|
||||
|> clear_session()
|
||||
end
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
|
||||
do: write_remember_me_cookie(conn, token)
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, token, _params, true), do: write_remember_me_cookie(conn, token)
|
||||
|
||||
defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn
|
||||
|
||||
defp write_remember_me_cookie(conn, token) do
|
||||
conn
|
||||
|> put_session(:user_remember_me, true)
|
||||
|> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
|
||||
end
|
||||
|
||||
defp put_token_in_session(conn, token) do
|
||||
put_session(conn, :user_token, token)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Plug for routes that require sudo mode.
|
||||
"""
|
||||
def require_sudo_mode(conn, _opts) do
|
||||
if Accounts.sudo_mode?(conn.assigns.current_scope.user, -10) do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must re-authenticate to access this page.")
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Plug for routes that require the user to not be authenticated.
|
||||
"""
|
||||
def redirect_if_user_is_authenticated(conn, _opts) do
|
||||
if conn.assigns.current_scope do
|
||||
conn
|
||||
|> redirect(to: signed_in_path(conn))
|
||||
|> halt()
|
||||
else
|
||||
conn
|
||||
end
|
||||
end
|
||||
|
||||
defp signed_in_path(_conn), do: ~p"/"
|
||||
|
||||
@doc """
|
||||
Plug for routes that require the user to be authenticated.
|
||||
"""
|
||||
def require_authenticated_user(conn, _opts) do
|
||||
if conn.assigns.current_scope && conn.assigns.current_scope.user do
|
||||
conn
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You must log in to access this page.")
|
||||
|> maybe_store_return_to()
|
||||
|> redirect(to: ~p"/users/log-in")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(%{method: "GET"} = conn) do
|
||||
put_session(conn, :user_return_to, current_path(conn))
|
||||
end
|
||||
|
||||
defp maybe_store_return_to(conn), do: conn
|
||||
|
||||
@doc """
|
||||
Plug to load the current organization from the URL slug.
|
||||
|
||||
Expects the organization slug to be in path_params as "org_slug".
|
||||
Verifies that the current user is a member of the organization.
|
||||
"""
|
||||
def load_current_organization(conn, _opts) do
|
||||
org_slug = conn.path_params["org_slug"]
|
||||
user = conn.assigns.current_scope && conn.assigns.current_scope.user
|
||||
|
||||
if org_slug && user do
|
||||
organization = Towerops.Organizations.get_organization_by_slug!(org_slug)
|
||||
membership = Towerops.Organizations.get_membership(organization.id, user.id)
|
||||
|
||||
if membership do
|
||||
conn
|
||||
|> assign(:current_organization, organization)
|
||||
|> assign(:current_membership, membership)
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "You don't have access to this organization.")
|
||||
|> redirect(to: ~p"/orgs")
|
||||
|> halt()
|
||||
end
|
||||
else
|
||||
conn
|
||||
|> put_flash(:error, "Organization not found.")
|
||||
|> redirect(to: ~p"/orgs")
|
||||
|> halt()
|
||||
end
|
||||
rescue
|
||||
Ecto.NoResultsError ->
|
||||
conn
|
||||
|> put_flash(:error, "Organization not found.")
|
||||
|> redirect(to: ~p"/orgs")
|
||||
|> halt()
|
||||
end
|
||||
end
|
||||
1
mix.exs
1
mix.exs
|
|
@ -40,6 +40,7 @@ defmodule Towerops.MixProject do
|
|||
# Type `mix help deps` for examples and options.
|
||||
defp deps do
|
||||
[
|
||||
{:bcrypt_elixir, "~> 3.0"},
|
||||
{:phoenix, "~> 1.8.3"},
|
||||
{:phoenix_ecto, "~> 4.5"},
|
||||
{:ecto_sql, "~> 3.13"},
|
||||
|
|
|
|||
2
mix.lock
2
mix.lock
|
|
@ -1,6 +1,8 @@
|
|||
%{
|
||||
"bandit": {:hex, :bandit, "1.9.0", "6dc1ff2c30948dfecf32db574cc3447c7b9d70e0b61140098df3818870b01b76", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "2538aaa1663b40ca9cbd8ca1f8a540cb49e5baf34c6ffef068369cc45f9146f2"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateUsersAuthTables do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
|
||||
|
||||
create table(:users, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :email, :citext, null: false
|
||||
add :hashed_password, :string
|
||||
add :confirmed_at, :utc_datetime
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:users, [:email])
|
||||
|
||||
create table(:users_tokens, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :token, :binary, null: false
|
||||
add :context, :string, null: false
|
||||
add :sent_to, :string
|
||||
add :authenticated_at, :utc_datetime
|
||||
|
||||
timestamps(type: :utc_datetime, updated_at: false)
|
||||
end
|
||||
|
||||
create index(:users_tokens, [:user_id])
|
||||
create unique_index(:users_tokens, [:context, :token])
|
||||
end
|
||||
end
|
||||
15
priv/repo/migrations/20251221192454_create_organizations.exs
Normal file
15
priv/repo/migrations/20251221192454_create_organizations.exs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateOrganizations do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:organizations, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
add :name, :string, null: false
|
||||
add :slug, :string, null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:organizations, [:slug])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateOrganizationMemberships do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:organization_memberships, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
|
||||
add :role, :string, null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:organization_memberships, [:organization_id, :user_id])
|
||||
create index(:organization_memberships, [:user_id])
|
||||
create index(:organization_memberships, [:organization_id])
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
defmodule Towerops.Repo.Migrations.CreateOrganizationInvitations do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:organization_invitations, primary_key: false) do
|
||||
add :id, :binary_id, primary_key: true
|
||||
|
||||
add :organization_id, references(:organizations, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
|
||||
add :email, :string, null: false
|
||||
add :role, :string, null: false
|
||||
add :token, :string, null: false
|
||||
add :invited_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
|
||||
add :accepted_at, :utc_datetime
|
||||
add :accepted_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
|
||||
add :expires_at, :utc_datetime, null: false
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
create unique_index(:organization_invitations, [:token])
|
||||
create index(:organization_invitations, [:organization_id])
|
||||
create index(:organization_invitations, [:email])
|
||||
end
|
||||
end
|
||||
|
|
@ -35,4 +35,45 @@ defmodule ToweropsWeb.ConnCase do
|
|||
Towerops.DataCase.setup_sandbox(tags)
|
||||
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Setup helper that registers and logs in users.
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
It stores an updated connection and a registered user in the
|
||||
test context.
|
||||
"""
|
||||
def register_and_log_in_user(%{conn: conn} = context) do
|
||||
user = Towerops.AccountsFixtures.user_fixture()
|
||||
scope = Towerops.Accounts.Scope.for_user(user)
|
||||
|
||||
opts =
|
||||
context
|
||||
|> Map.take([:token_authenticated_at])
|
||||
|> Enum.to_list()
|
||||
|
||||
%{conn: log_in_user(conn, user, opts), user: user, scope: scope}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Logs the given `user` into the `conn`.
|
||||
|
||||
It returns an updated `conn`.
|
||||
"""
|
||||
def log_in_user(conn, user, opts \\ []) do
|
||||
token = Towerops.Accounts.generate_user_session_token(user)
|
||||
|
||||
maybe_set_token_authenticated_at(token, opts[:token_authenticated_at])
|
||||
|
||||
conn
|
||||
|> Phoenix.ConnTest.init_test_session(%{})
|
||||
|> Plug.Conn.put_session(:user_token, token)
|
||||
end
|
||||
|
||||
defp maybe_set_token_authenticated_at(_token, nil), do: nil
|
||||
|
||||
defp maybe_set_token_authenticated_at(token, authenticated_at) do
|
||||
Towerops.AccountsFixtures.override_token_authenticated_at(token, authenticated_at)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
89
test/support/fixtures/accounts_fixtures.ex
Normal file
89
test/support/fixtures/accounts_fixtures.ex
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
defmodule Towerops.AccountsFixtures do
|
||||
@moduledoc """
|
||||
This module defines test helpers for creating
|
||||
entities via the `Towerops.Accounts` context.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias Towerops.Accounts.Scope
|
||||
|
||||
def unique_user_email, do: "user#{System.unique_integer()}@example.com"
|
||||
def valid_user_password, do: "hello world!"
|
||||
|
||||
def valid_user_attributes(attrs \\ %{}) do
|
||||
Enum.into(attrs, %{
|
||||
email: unique_user_email()
|
||||
})
|
||||
end
|
||||
|
||||
def unconfirmed_user_fixture(attrs \\ %{}) do
|
||||
{:ok, user} =
|
||||
attrs
|
||||
|> valid_user_attributes()
|
||||
|> Accounts.register_user()
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def user_fixture(attrs \\ %{}) do
|
||||
user = unconfirmed_user_fixture(attrs)
|
||||
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_login_instructions(user, url)
|
||||
end)
|
||||
|
||||
{:ok, {user, _expired_tokens}} =
|
||||
Accounts.login_user_by_magic_link(token)
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def user_scope_fixture do
|
||||
user = user_fixture()
|
||||
user_scope_fixture(user)
|
||||
end
|
||||
|
||||
def user_scope_fixture(user) do
|
||||
Scope.for_user(user)
|
||||
end
|
||||
|
||||
def set_password(user) do
|
||||
{:ok, {user, _expired_tokens}} =
|
||||
Accounts.update_user_password(user, %{password: valid_user_password()})
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def extract_user_token(fun) do
|
||||
{:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
|
||||
[_, token | _] = String.split(captured_email.text_body, "[TOKEN]")
|
||||
token
|
||||
end
|
||||
|
||||
def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do
|
||||
Towerops.Repo.update_all(
|
||||
from(t in Accounts.UserToken,
|
||||
where: t.token == ^token
|
||||
),
|
||||
set: [authenticated_at: authenticated_at]
|
||||
)
|
||||
end
|
||||
|
||||
def generate_user_magic_link_token(user) do
|
||||
{encoded_token, user_token} = Accounts.UserToken.build_email_token(user, "login")
|
||||
Towerops.Repo.insert!(user_token)
|
||||
{encoded_token, user_token.token}
|
||||
end
|
||||
|
||||
def offset_user_token(token, amount_to_add, unit) do
|
||||
dt = DateTime.add(DateTime.utc_now(:second), amount_to_add, unit)
|
||||
|
||||
Towerops.Repo.update_all(
|
||||
from(ut in Accounts.UserToken, where: ut.token == ^token),
|
||||
set: [inserted_at: dt, authenticated_at: dt]
|
||||
)
|
||||
end
|
||||
end
|
||||
398
test/towerops/accounts_test.exs
Normal file
398
test/towerops/accounts_test.exs
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
defmodule Towerops.AccountsTest do
|
||||
use Towerops.DataCase
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias Towerops.Accounts.User
|
||||
alias Towerops.Accounts.UserToken
|
||||
|
||||
describe "get_user_by_email/1" do
|
||||
test "does not return the user if the email does not exist" do
|
||||
refute Accounts.get_user_by_email("unknown@example.com")
|
||||
end
|
||||
|
||||
test "returns the user if the email exists" do
|
||||
%{id: id} = user = user_fixture()
|
||||
assert %User{id: ^id} = Accounts.get_user_by_email(user.email)
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_user_by_email_and_password/2" do
|
||||
test "does not return the user if the email does not exist" do
|
||||
refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
|
||||
end
|
||||
|
||||
test "does not return the user if the password is not valid" do
|
||||
user = set_password(user_fixture())
|
||||
refute Accounts.get_user_by_email_and_password(user.email, "invalid")
|
||||
end
|
||||
|
||||
test "returns the user if the email and password are valid" do
|
||||
%{id: id} = user = set_password(user_fixture())
|
||||
|
||||
assert %User{id: ^id} =
|
||||
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_user!/1" do
|
||||
test "raises if id is invalid" do
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
Accounts.get_user!("11111111-1111-1111-1111-111111111111")
|
||||
end
|
||||
end
|
||||
|
||||
test "returns the user with the given id" do
|
||||
%{id: id} = user = user_fixture()
|
||||
assert %User{id: ^id} = Accounts.get_user!(user.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "register_user/1" do
|
||||
test "requires email to be set" do
|
||||
{:error, changeset} = Accounts.register_user(%{})
|
||||
|
||||
assert %{email: ["can't be blank"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "validates email when given" do
|
||||
{:error, changeset} = Accounts.register_user(%{email: "not valid"})
|
||||
|
||||
assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "validates maximum values for email for security" do
|
||||
too_long = String.duplicate("db", 100)
|
||||
{:error, changeset} = Accounts.register_user(%{email: too_long})
|
||||
assert "should be at most 160 character(s)" in errors_on(changeset).email
|
||||
end
|
||||
|
||||
test "validates email uniqueness" do
|
||||
%{email: email} = user_fixture()
|
||||
{:error, changeset} = Accounts.register_user(%{email: email})
|
||||
assert "has already been taken" in errors_on(changeset).email
|
||||
|
||||
# Now try with the uppercased email too, to check that email case is ignored.
|
||||
{:error, changeset} = Accounts.register_user(%{email: String.upcase(email)})
|
||||
assert "has already been taken" in errors_on(changeset).email
|
||||
end
|
||||
|
||||
test "registers users without password" do
|
||||
email = unique_user_email()
|
||||
{:ok, user} = Accounts.register_user(valid_user_attributes(email: email))
|
||||
assert user.email == email
|
||||
assert is_nil(user.hashed_password)
|
||||
assert is_nil(user.confirmed_at)
|
||||
assert is_nil(user.password)
|
||||
end
|
||||
end
|
||||
|
||||
describe "sudo_mode?/2" do
|
||||
test "validates the authenticated_at time" do
|
||||
now = DateTime.utc_now()
|
||||
|
||||
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.utc_now()})
|
||||
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -19, :minute)})
|
||||
refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -21, :minute)})
|
||||
|
||||
# minute override
|
||||
refute Accounts.sudo_mode?(
|
||||
%User{authenticated_at: DateTime.add(now, -11, :minute)},
|
||||
-10
|
||||
)
|
||||
|
||||
# not authenticated
|
||||
refute Accounts.sudo_mode?(%User{})
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_user_email/3" do
|
||||
test "returns a user changeset" do
|
||||
assert %Ecto.Changeset{} = changeset = Accounts.change_user_email(%User{})
|
||||
assert changeset.required == [:email]
|
||||
end
|
||||
end
|
||||
|
||||
describe "deliver_user_update_email_instructions/3" do
|
||||
setup do
|
||||
%{user: user_fixture()}
|
||||
end
|
||||
|
||||
test "sends token through notification", %{user: user} do
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_user_update_email_instructions(user, "current@example.com", url)
|
||||
end)
|
||||
|
||||
{:ok, token} = Base.url_decode64(token, padding: false)
|
||||
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
|
||||
assert user_token.user_id == user.id
|
||||
assert user_token.sent_to == user.email
|
||||
assert user_token.context == "change:current@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_user_email/2" do
|
||||
setup do
|
||||
user = unconfirmed_user_fixture()
|
||||
email = unique_user_email()
|
||||
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
|
||||
end)
|
||||
|
||||
%{user: user, token: token, email: email}
|
||||
end
|
||||
|
||||
test "updates the email with a valid token", %{user: user, token: token, email: email} do
|
||||
assert {:ok, %{email: ^email}} = Accounts.update_user_email(user, token)
|
||||
changed_user = Repo.get!(User, user.id)
|
||||
assert changed_user.email != user.email
|
||||
assert changed_user.email == email
|
||||
refute Repo.get_by(UserToken, user_id: user.id)
|
||||
end
|
||||
|
||||
test "does not update email with invalid token", %{user: user} do
|
||||
assert Accounts.update_user_email(user, "oops") ==
|
||||
{:error, :transaction_aborted}
|
||||
|
||||
assert Repo.get!(User, user.id).email == user.email
|
||||
assert Repo.get_by(UserToken, user_id: user.id)
|
||||
end
|
||||
|
||||
test "does not update email if user email changed", %{user: user, token: token} do
|
||||
assert Accounts.update_user_email(%{user | email: "current@example.com"}, token) ==
|
||||
{:error, :transaction_aborted}
|
||||
|
||||
assert Repo.get!(User, user.id).email == user.email
|
||||
assert Repo.get_by(UserToken, user_id: user.id)
|
||||
end
|
||||
|
||||
test "does not update email if token expired", %{user: user, token: token} do
|
||||
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
|
||||
|
||||
assert Accounts.update_user_email(user, token) ==
|
||||
{:error, :transaction_aborted}
|
||||
|
||||
assert Repo.get!(User, user.id).email == user.email
|
||||
assert Repo.get_by(UserToken, user_id: user.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_user_password/3" do
|
||||
test "returns a user changeset" do
|
||||
assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{})
|
||||
assert changeset.required == [:password]
|
||||
end
|
||||
|
||||
test "allows fields to be set" do
|
||||
changeset =
|
||||
Accounts.change_user_password(
|
||||
%User{},
|
||||
%{
|
||||
"password" => "new valid password"
|
||||
},
|
||||
hash_password: false
|
||||
)
|
||||
|
||||
assert changeset.valid?
|
||||
assert get_change(changeset, :password) == "new valid password"
|
||||
assert is_nil(get_change(changeset, :hashed_password))
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_user_password/2" do
|
||||
setup do
|
||||
%{user: user_fixture()}
|
||||
end
|
||||
|
||||
test "validates password", %{user: user} do
|
||||
{:error, changeset} =
|
||||
Accounts.update_user_password(user, %{
|
||||
password: "not valid",
|
||||
password_confirmation: "another"
|
||||
})
|
||||
|
||||
assert %{
|
||||
password: ["should be at least 12 character(s)"],
|
||||
password_confirmation: ["does not match password"]
|
||||
} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "validates maximum values for password for security", %{user: user} do
|
||||
too_long = String.duplicate("db", 100)
|
||||
|
||||
{:error, changeset} =
|
||||
Accounts.update_user_password(user, %{password: too_long})
|
||||
|
||||
assert "should be at most 72 character(s)" in errors_on(changeset).password
|
||||
end
|
||||
|
||||
test "updates the password", %{user: user} do
|
||||
{:ok, {user, expired_tokens}} =
|
||||
Accounts.update_user_password(user, %{
|
||||
password: "new valid password"
|
||||
})
|
||||
|
||||
assert expired_tokens == []
|
||||
assert is_nil(user.password)
|
||||
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
|
||||
end
|
||||
|
||||
test "deletes all tokens for the given user", %{user: user} do
|
||||
_ = Accounts.generate_user_session_token(user)
|
||||
|
||||
{:ok, {_, _}} =
|
||||
Accounts.update_user_password(user, %{
|
||||
password: "new valid password"
|
||||
})
|
||||
|
||||
refute Repo.get_by(UserToken, user_id: user.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "generate_user_session_token/1" do
|
||||
setup do
|
||||
%{user: user_fixture()}
|
||||
end
|
||||
|
||||
test "generates a token", %{user: user} do
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
assert user_token = Repo.get_by(UserToken, token: token)
|
||||
assert user_token.context == "session"
|
||||
assert user_token.authenticated_at
|
||||
|
||||
# Creating the same token for another user should fail
|
||||
assert_raise Ecto.ConstraintError, fn ->
|
||||
Repo.insert!(%UserToken{
|
||||
token: user_token.token,
|
||||
user_id: user_fixture().id,
|
||||
context: "session"
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
test "duplicates the authenticated_at of given user in new token", %{user: user} do
|
||||
user = %{user | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)}
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
assert user_token = Repo.get_by(UserToken, token: token)
|
||||
assert user_token.authenticated_at == user.authenticated_at
|
||||
assert DateTime.after?(user_token.inserted_at, user.authenticated_at)
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_user_by_session_token/1" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
%{user: user, token: token}
|
||||
end
|
||||
|
||||
test "returns user by token", %{user: user, token: token} do
|
||||
assert {session_user, token_inserted_at} = Accounts.get_user_by_session_token(token)
|
||||
assert session_user.id == user.id
|
||||
assert session_user.authenticated_at
|
||||
assert token_inserted_at
|
||||
end
|
||||
|
||||
test "does not return user for invalid token" do
|
||||
refute Accounts.get_user_by_session_token("oops")
|
||||
end
|
||||
|
||||
test "does not return user for expired token", %{token: token} do
|
||||
dt = ~N[2020-01-01 00:00:00]
|
||||
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: dt, authenticated_at: dt])
|
||||
refute Accounts.get_user_by_session_token(token)
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_user_by_magic_link_token/1" do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
|
||||
%{user: user, token: encoded_token}
|
||||
end
|
||||
|
||||
test "returns user by token", %{user: user, token: token} do
|
||||
assert session_user = Accounts.get_user_by_magic_link_token(token)
|
||||
assert session_user.id == user.id
|
||||
end
|
||||
|
||||
test "does not return user for invalid token" do
|
||||
refute Accounts.get_user_by_magic_link_token("oops")
|
||||
end
|
||||
|
||||
test "does not return user for expired token", %{token: token} do
|
||||
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: ~N[2020-01-01 00:00:00]])
|
||||
refute Accounts.get_user_by_magic_link_token(token)
|
||||
end
|
||||
end
|
||||
|
||||
describe "login_user_by_magic_link/1" do
|
||||
test "confirms user and expires tokens" do
|
||||
user = unconfirmed_user_fixture()
|
||||
refute user.confirmed_at
|
||||
{encoded_token, hashed_token} = generate_user_magic_link_token(user)
|
||||
|
||||
assert {:ok, {user, [%{token: ^hashed_token}]}} =
|
||||
Accounts.login_user_by_magic_link(encoded_token)
|
||||
|
||||
assert user.confirmed_at
|
||||
end
|
||||
|
||||
test "returns user and (deleted) token for confirmed user" do
|
||||
user = user_fixture()
|
||||
assert user.confirmed_at
|
||||
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
|
||||
assert {:ok, {^user, []}} = Accounts.login_user_by_magic_link(encoded_token)
|
||||
# one time use only
|
||||
assert {:error, :not_found} = Accounts.login_user_by_magic_link(encoded_token)
|
||||
end
|
||||
|
||||
test "raises when unconfirmed user has password set" do
|
||||
user = unconfirmed_user_fixture()
|
||||
{1, nil} = Repo.update_all(User, set: [hashed_password: "hashed"])
|
||||
{encoded_token, _hashed_token} = generate_user_magic_link_token(user)
|
||||
|
||||
assert_raise RuntimeError, ~r/magic link log in is not allowed/, fn ->
|
||||
Accounts.login_user_by_magic_link(encoded_token)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_user_session_token/1" do
|
||||
test "deletes the token" do
|
||||
user = user_fixture()
|
||||
token = Accounts.generate_user_session_token(user)
|
||||
assert Accounts.delete_user_session_token(token) == :ok
|
||||
refute Accounts.get_user_by_session_token(token)
|
||||
end
|
||||
end
|
||||
|
||||
describe "deliver_login_instructions/2" do
|
||||
setup do
|
||||
%{user: unconfirmed_user_fixture()}
|
||||
end
|
||||
|
||||
test "sends token through notification", %{user: user} do
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_login_instructions(user, url)
|
||||
end)
|
||||
|
||||
{:ok, token} = Base.url_decode64(token, padding: false)
|
||||
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
|
||||
assert user_token.user_id == user.id
|
||||
assert user_token.sent_to == user.email
|
||||
assert user_token.context == "login"
|
||||
end
|
||||
end
|
||||
|
||||
describe "inspect/2 for the User module" do
|
||||
test "does not include password" do
|
||||
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
defmodule ToweropsWeb.UserRegistrationControllerTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
describe "GET /users/register" do
|
||||
test "renders registration page", %{conn: conn} do
|
||||
conn = get(conn, ~p"/users/register")
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Register"
|
||||
assert response =~ ~p"/users/log-in"
|
||||
assert response =~ ~p"/users/register"
|
||||
end
|
||||
|
||||
test "redirects if already logged in", %{conn: conn} do
|
||||
conn = conn |> log_in_user(user_fixture()) |> get(~p"/users/register")
|
||||
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /users/register" do
|
||||
@tag :capture_log
|
||||
test "creates account but does not log in", %{conn: conn} do
|
||||
email = unique_user_email()
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/users/register", %{
|
||||
"user" => valid_user_attributes(email: email)
|
||||
})
|
||||
|
||||
refute get_session(conn, :user_token)
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
|
||||
assert conn.assigns.flash["info"] =~
|
||||
~r/An email was sent to .*, please access it to confirm your account/
|
||||
end
|
||||
|
||||
test "render errors for invalid data", %{conn: conn} do
|
||||
conn =
|
||||
post(conn, ~p"/users/register", %{
|
||||
"user" => %{"email" => "with spaces"}
|
||||
})
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Register"
|
||||
assert response =~ "must have the @ sign and no spaces"
|
||||
end
|
||||
end
|
||||
end
|
||||
221
test/towerops_web/controllers/user_session_controller_test.exs
Normal file
221
test/towerops_web/controllers/user_session_controller_test.exs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
defmodule ToweropsWeb.UserSessionControllerTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Accounts
|
||||
|
||||
setup do
|
||||
%{unconfirmed_user: unconfirmed_user_fixture(), user: user_fixture()}
|
||||
end
|
||||
|
||||
describe "GET /users/log-in" do
|
||||
test "renders login page", %{conn: conn} do
|
||||
conn = get(conn, ~p"/users/log-in")
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Log in"
|
||||
assert response =~ ~p"/users/register"
|
||||
assert response =~ "Log in with email"
|
||||
end
|
||||
|
||||
test "renders login page with email filled in (sudo mode)", %{conn: conn, user: user} do
|
||||
html =
|
||||
conn
|
||||
|> log_in_user(user)
|
||||
|> get(~p"/users/log-in")
|
||||
|> html_response(200)
|
||||
|
||||
assert html =~ "You need to reauthenticate"
|
||||
refute html =~ "Register"
|
||||
assert html =~ "Log in with email"
|
||||
|
||||
assert html =~
|
||||
~s(<input type="email" name="user[email]" id="login_form_magic_email" value="#{user.email}")
|
||||
end
|
||||
|
||||
test "renders login page (email + password)", %{conn: conn} do
|
||||
conn = get(conn, ~p"/users/log-in?mode=password")
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Log in"
|
||||
assert response =~ ~p"/users/register"
|
||||
assert response =~ "Log in with email"
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /users/log-in/:token" do
|
||||
test "renders confirmation page for unconfirmed user", %{conn: conn, unconfirmed_user: user} do
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_login_instructions(user, url)
|
||||
end)
|
||||
|
||||
conn = get(conn, ~p"/users/log-in/#{token}")
|
||||
assert html_response(conn, 200) =~ "Confirm and stay logged in"
|
||||
end
|
||||
|
||||
test "renders login page for confirmed user", %{conn: conn, user: user} do
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_login_instructions(user, url)
|
||||
end)
|
||||
|
||||
conn = get(conn, ~p"/users/log-in/#{token}")
|
||||
html = html_response(conn, 200)
|
||||
refute html =~ "Confirm my account"
|
||||
assert html =~ "Log in"
|
||||
end
|
||||
|
||||
test "raises error for invalid token", %{conn: conn} do
|
||||
conn = get(conn, ~p"/users/log-in/invalid-token")
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||
"Magic link is invalid or it has expired."
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /users/log-in - email and password" do
|
||||
test "logs the user in", %{conn: conn, user: user} do
|
||||
user = set_password(user)
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/users/log-in", %{
|
||||
"user" => %{"email" => user.email, "password" => valid_user_password()}
|
||||
})
|
||||
|
||||
assert get_session(conn, :user_token)
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
|
||||
# Now do a logged in request and assert on the menu
|
||||
conn = get(conn, ~p"/")
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ user.email
|
||||
assert response =~ ~p"/users/settings"
|
||||
assert response =~ ~p"/users/log-out"
|
||||
end
|
||||
|
||||
test "logs the user in with remember me", %{conn: conn, user: user} do
|
||||
user = set_password(user)
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/users/log-in", %{
|
||||
"user" => %{
|
||||
"email" => user.email,
|
||||
"password" => valid_user_password(),
|
||||
"remember_me" => "true"
|
||||
}
|
||||
})
|
||||
|
||||
assert conn.resp_cookies["_towerops_web_user_remember_me"]
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
end
|
||||
|
||||
test "logs the user in with return to", %{conn: conn, user: user} do
|
||||
user = set_password(user)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> init_test_session(user_return_to: "/foo/bar")
|
||||
|> post(~p"/users/log-in", %{
|
||||
"user" => %{
|
||||
"email" => user.email,
|
||||
"password" => valid_user_password()
|
||||
}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == "/foo/bar"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!"
|
||||
end
|
||||
|
||||
test "emits error message with invalid credentials", %{conn: conn, user: user} do
|
||||
conn =
|
||||
post(conn, ~p"/users/log-in?mode=password", %{
|
||||
"user" => %{"email" => user.email, "password" => "invalid_password"}
|
||||
})
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Log in"
|
||||
assert response =~ "Invalid email or password"
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /users/log-in - magic link" do
|
||||
test "sends magic link email when user exists", %{conn: conn, user: user} do
|
||||
conn =
|
||||
post(conn, ~p"/users/log-in", %{
|
||||
"user" => %{"email" => user.email}
|
||||
})
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "If your email is in our system"
|
||||
assert Towerops.Repo.get_by!(Accounts.UserToken, user_id: user.id).context == "login"
|
||||
end
|
||||
|
||||
test "logs the user in", %{conn: conn, user: user} do
|
||||
{token, _hashed_token} = generate_user_magic_link_token(user)
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/users/log-in", %{
|
||||
"user" => %{"token" => token}
|
||||
})
|
||||
|
||||
assert get_session(conn, :user_token)
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
|
||||
# Now do a logged in request and assert on the menu
|
||||
conn = get(conn, ~p"/")
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ user.email
|
||||
assert response =~ ~p"/users/settings"
|
||||
assert response =~ ~p"/users/log-out"
|
||||
end
|
||||
|
||||
test "confirms unconfirmed user", %{conn: conn, unconfirmed_user: user} do
|
||||
{token, _hashed_token} = generate_user_magic_link_token(user)
|
||||
refute user.confirmed_at
|
||||
|
||||
conn =
|
||||
post(conn, ~p"/users/log-in", %{
|
||||
"user" => %{"token" => token},
|
||||
"_action" => "confirmed"
|
||||
})
|
||||
|
||||
assert get_session(conn, :user_token)
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "User confirmed successfully."
|
||||
|
||||
assert Accounts.get_user!(user.id).confirmed_at
|
||||
|
||||
# Now do a logged in request and assert on the menu
|
||||
conn = get(conn, ~p"/")
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ user.email
|
||||
assert response =~ ~p"/users/settings"
|
||||
assert response =~ ~p"/users/log-out"
|
||||
end
|
||||
|
||||
test "emits error message when magic link is invalid", %{conn: conn} do
|
||||
conn =
|
||||
post(conn, ~p"/users/log-in", %{
|
||||
"user" => %{"token" => "invalid"}
|
||||
})
|
||||
|
||||
assert html_response(conn, 200) =~ "The link is invalid or it has expired."
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE /users/log-out" do
|
||||
test "logs the user out", %{conn: conn, user: user} do
|
||||
conn = conn |> log_in_user(user) |> delete(~p"/users/log-out")
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
refute get_session(conn, :user_token)
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
|
||||
end
|
||||
|
||||
test "succeeds even if the user is not logged in", %{conn: conn} do
|
||||
conn = delete(conn, ~p"/users/log-out")
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
refute get_session(conn, :user_token)
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
|
||||
end
|
||||
end
|
||||
end
|
||||
149
test/towerops_web/controllers/user_settings_controller_test.exs
Normal file
149
test/towerops_web/controllers/user_settings_controller_test.exs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
defmodule ToweropsWeb.UserSettingsControllerTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Accounts
|
||||
|
||||
setup :register_and_log_in_user
|
||||
|
||||
describe "GET /users/settings" do
|
||||
test "renders settings page", %{conn: conn} do
|
||||
conn = get(conn, ~p"/users/settings")
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Settings"
|
||||
end
|
||||
|
||||
test "redirects if user is not logged in" do
|
||||
conn = build_conn()
|
||||
conn = get(conn, ~p"/users/settings")
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
end
|
||||
|
||||
@tag token_authenticated_at: DateTime.add(DateTime.utc_now(:second), -11, :minute)
|
||||
test "redirects if user is not in sudo mode", %{conn: conn} do
|
||||
conn = get(conn, ~p"/users/settings")
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||
"You must re-authenticate to access this page."
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT /users/settings (change password form)" do
|
||||
test "updates the user password and resets tokens", %{conn: conn, user: user} do
|
||||
new_password_conn =
|
||||
put(conn, ~p"/users/settings", %{
|
||||
"action" => "update_password",
|
||||
"user" => %{
|
||||
"password" => "new valid password",
|
||||
"password_confirmation" => "new valid password"
|
||||
}
|
||||
})
|
||||
|
||||
assert redirected_to(new_password_conn) == ~p"/users/settings"
|
||||
|
||||
assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
|
||||
|
||||
assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
|
||||
"Password updated successfully"
|
||||
|
||||
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
|
||||
end
|
||||
|
||||
test "does not update password on invalid data", %{conn: conn} do
|
||||
old_password_conn =
|
||||
put(conn, ~p"/users/settings", %{
|
||||
"action" => "update_password",
|
||||
"user" => %{
|
||||
"password" => "too short",
|
||||
"password_confirmation" => "does not match"
|
||||
}
|
||||
})
|
||||
|
||||
response = html_response(old_password_conn, 200)
|
||||
assert response =~ "Settings"
|
||||
assert response =~ "should be at least 12 character(s)"
|
||||
assert response =~ "does not match password"
|
||||
|
||||
assert get_session(old_password_conn, :user_token) == get_session(conn, :user_token)
|
||||
end
|
||||
end
|
||||
|
||||
describe "PUT /users/settings (change email form)" do
|
||||
@tag :capture_log
|
||||
test "updates the user email", %{conn: conn, user: user} do
|
||||
conn =
|
||||
put(conn, ~p"/users/settings", %{
|
||||
"action" => "update_email",
|
||||
"user" => %{"email" => unique_user_email()}
|
||||
})
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
|
||||
"A link to confirm your email"
|
||||
|
||||
assert Accounts.get_user_by_email(user.email)
|
||||
end
|
||||
|
||||
test "does not update email on invalid data", %{conn: conn} do
|
||||
conn =
|
||||
put(conn, ~p"/users/settings", %{
|
||||
"action" => "update_email",
|
||||
"user" => %{"email" => "with spaces"}
|
||||
})
|
||||
|
||||
response = html_response(conn, 200)
|
||||
assert response =~ "Settings"
|
||||
assert response =~ "must have the @ sign and no spaces"
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /users/settings/confirm-email/:token" do
|
||||
setup %{user: user} do
|
||||
email = unique_user_email()
|
||||
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_user_update_email_instructions(%{user | email: email}, user.email, url)
|
||||
end)
|
||||
|
||||
%{token: token, email: email}
|
||||
end
|
||||
|
||||
test "updates the user email once", %{conn: conn, user: user, token: token, email: email} do
|
||||
conn = get(conn, ~p"/users/settings/confirm-email/#{token}")
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~
|
||||
"Email changed successfully"
|
||||
|
||||
refute Accounts.get_user_by_email(user.email)
|
||||
assert Accounts.get_user_by_email(email)
|
||||
|
||||
conn = get(conn, ~p"/users/settings/confirm-email/#{token}")
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
|
||||
"Email change link is invalid or it has expired"
|
||||
end
|
||||
|
||||
test "does not update email with invalid token", %{conn: conn, user: user} do
|
||||
conn = get(conn, ~p"/users/settings/confirm-email/oops")
|
||||
assert redirected_to(conn) == ~p"/users/settings"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) =~
|
||||
"Email change link is invalid or it has expired"
|
||||
|
||||
assert Accounts.get_user_by_email(user.email)
|
||||
end
|
||||
|
||||
test "redirects if user is not logged in", %{token: token} do
|
||||
conn = build_conn()
|
||||
conn = get(conn, ~p"/users/settings/confirm-email/#{token}")
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
end
|
||||
end
|
||||
end
|
||||
293
test/towerops_web/user_auth_test.exs
Normal file
293
test/towerops_web/user_auth_test.exs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
defmodule ToweropsWeb.UserAuthTest do
|
||||
use ToweropsWeb.ConnCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
|
||||
alias Towerops.Accounts
|
||||
alias Towerops.Accounts.Scope
|
||||
alias ToweropsWeb.UserAuth
|
||||
|
||||
@remember_me_cookie "_towerops_web_user_remember_me"
|
||||
@remember_me_cookie_max_age 60 * 60 * 24 * 14
|
||||
|
||||
setup %{conn: conn} do
|
||||
conn =
|
||||
conn
|
||||
|> Map.replace!(:secret_key_base, ToweropsWeb.Endpoint.config(:secret_key_base))
|
||||
|> init_test_session(%{})
|
||||
|
||||
%{user: %{user_fixture() | authenticated_at: DateTime.utc_now(:second)}, conn: conn}
|
||||
end
|
||||
|
||||
describe "log_in_user/3" do
|
||||
test "stores the user token in the session", %{conn: conn, user: user} do
|
||||
conn = UserAuth.log_in_user(conn, user)
|
||||
assert token = get_session(conn, :user_token)
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
assert Accounts.get_user_by_session_token(token)
|
||||
end
|
||||
|
||||
test "clears everything previously stored in the session", %{conn: conn, user: user} do
|
||||
conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
|
||||
refute get_session(conn, :to_be_removed)
|
||||
end
|
||||
|
||||
test "keeps session when re-authenticating", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_user(user))
|
||||
|> put_session(:to_be_removed, "value")
|
||||
|> UserAuth.log_in_user(user)
|
||||
|
||||
assert get_session(conn, :to_be_removed)
|
||||
end
|
||||
|
||||
test "clears session when user does not match when re-authenticating", %{
|
||||
conn: conn,
|
||||
user: user
|
||||
} do
|
||||
other_user = user_fixture()
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_user(other_user))
|
||||
|> put_session(:to_be_removed, "value")
|
||||
|> UserAuth.log_in_user(user)
|
||||
|
||||
refute get_session(conn, :to_be_removed)
|
||||
end
|
||||
|
||||
test "redirects to the configured path", %{conn: conn, user: user} do
|
||||
conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user)
|
||||
assert redirected_to(conn) == "/hello"
|
||||
end
|
||||
|
||||
test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do
|
||||
conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
|
||||
assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
|
||||
assert get_session(conn, :user_remember_me) == true
|
||||
|
||||
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
|
||||
assert signed_token != get_session(conn, :user_token)
|
||||
assert max_age == @remember_me_cookie_max_age
|
||||
end
|
||||
|
||||
test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do
|
||||
conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
|
||||
assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
|
||||
assert get_session(conn, :user_remember_me) == true
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> recycle()
|
||||
|> Map.replace!(:secret_key_base, ToweropsWeb.Endpoint.config(:secret_key_base))
|
||||
|> fetch_cookies()
|
||||
|> init_test_session(%{user_remember_me: true})
|
||||
|
||||
# the conn is already logged in and has the remember_me cookie set,
|
||||
# now we log in again and even without explicitly setting remember_me,
|
||||
# the cookie should be set again
|
||||
conn = UserAuth.log_in_user(conn, user, %{})
|
||||
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
|
||||
assert signed_token != get_session(conn, :user_token)
|
||||
assert max_age == @remember_me_cookie_max_age
|
||||
assert get_session(conn, :user_remember_me) == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "logout_user/1" do
|
||||
test "erases session and cookies", %{conn: conn, user: user} do
|
||||
user_token = Accounts.generate_user_session_token(user)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_session(:user_token, user_token)
|
||||
|> put_req_cookie(@remember_me_cookie, user_token)
|
||||
|> fetch_cookies()
|
||||
|> UserAuth.log_out_user()
|
||||
|
||||
refute get_session(conn, :user_token)
|
||||
refute conn.cookies[@remember_me_cookie]
|
||||
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
refute Accounts.get_user_by_session_token(user_token)
|
||||
end
|
||||
|
||||
test "works even if user is already logged out", %{conn: conn} do
|
||||
conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
|
||||
refute get_session(conn, :user_token)
|
||||
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_current_scope_for_user/2" do
|
||||
test "authenticates user from session", %{conn: conn, user: user} do
|
||||
user_token = Accounts.generate_user_session_token(user)
|
||||
|
||||
conn =
|
||||
conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_scope_for_user([])
|
||||
|
||||
assert conn.assigns.current_scope.user.id == user.id
|
||||
assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at
|
||||
assert get_session(conn, :user_token) == user_token
|
||||
end
|
||||
|
||||
test "authenticates user from cookies", %{conn: conn, user: user} do
|
||||
logged_in_conn =
|
||||
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
|
||||
|
||||
user_token = logged_in_conn.cookies[@remember_me_cookie]
|
||||
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_req_cookie(@remember_me_cookie, signed_token)
|
||||
|> UserAuth.fetch_current_scope_for_user([])
|
||||
|
||||
assert conn.assigns.current_scope.user.id == user.id
|
||||
assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at
|
||||
assert get_session(conn, :user_token) == user_token
|
||||
assert get_session(conn, :user_remember_me)
|
||||
end
|
||||
|
||||
test "does not authenticate if data is missing", %{conn: conn, user: user} do
|
||||
_ = Accounts.generate_user_session_token(user)
|
||||
conn = UserAuth.fetch_current_scope_for_user(conn, [])
|
||||
refute get_session(conn, :user_token)
|
||||
refute conn.assigns.current_scope
|
||||
end
|
||||
|
||||
test "reissues a new token after a few days and refreshes cookie", %{conn: conn, user: user} do
|
||||
logged_in_conn =
|
||||
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
|
||||
|
||||
token = logged_in_conn.cookies[@remember_me_cookie]
|
||||
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
|
||||
|
||||
offset_user_token(token, -10, :day)
|
||||
{user, _} = Accounts.get_user_by_session_token(token)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> put_session(:user_token, token)
|
||||
|> put_session(:user_remember_me, true)
|
||||
|> put_req_cookie(@remember_me_cookie, signed_token)
|
||||
|> UserAuth.fetch_current_scope_for_user([])
|
||||
|
||||
assert conn.assigns.current_scope.user.id == user.id
|
||||
assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at
|
||||
assert new_token = get_session(conn, :user_token)
|
||||
assert new_token != token
|
||||
assert %{value: new_signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
|
||||
assert new_signed_token != signed_token
|
||||
assert max_age == @remember_me_cookie_max_age
|
||||
end
|
||||
end
|
||||
|
||||
describe "require_sudo_mode/2" do
|
||||
test "allows users that have authenticated in the last 10 minutes", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> fetch_flash()
|
||||
|> assign(:current_scope, Scope.for_user(user))
|
||||
|> UserAuth.require_sudo_mode([])
|
||||
|
||||
refute conn.halted
|
||||
refute conn.status
|
||||
end
|
||||
|
||||
test "redirects when authentication is too old", %{conn: conn, user: user} do
|
||||
eleven_minutes_ago = :second |> DateTime.utc_now() |> DateTime.add(-11, :minute)
|
||||
user = %{user | authenticated_at: eleven_minutes_ago}
|
||||
user_token = Accounts.generate_user_session_token(user)
|
||||
{user, token_inserted_at} = Accounts.get_user_by_session_token(user_token)
|
||||
assert DateTime.after?(token_inserted_at, user.authenticated_at)
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> fetch_flash()
|
||||
|> assign(:current_scope, Scope.for_user(user))
|
||||
|> UserAuth.require_sudo_mode([])
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||
"You must re-authenticate to access this page."
|
||||
end
|
||||
end
|
||||
|
||||
describe "redirect_if_user_is_authenticated/2" do
|
||||
setup %{conn: conn} do
|
||||
%{conn: UserAuth.fetch_current_scope_for_user(conn, [])}
|
||||
end
|
||||
|
||||
test "redirects if user is authenticated", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_user(user))
|
||||
|> UserAuth.redirect_if_user_is_authenticated([])
|
||||
|
||||
assert conn.halted
|
||||
assert redirected_to(conn) == ~p"/"
|
||||
end
|
||||
|
||||
test "does not redirect if user is not authenticated", %{conn: conn} do
|
||||
conn = UserAuth.redirect_if_user_is_authenticated(conn, [])
|
||||
refute conn.halted
|
||||
refute conn.status
|
||||
end
|
||||
end
|
||||
|
||||
describe "require_authenticated_user/2" do
|
||||
setup %{conn: conn} do
|
||||
%{conn: UserAuth.fetch_current_scope_for_user(conn, [])}
|
||||
end
|
||||
|
||||
test "redirects if user is not authenticated", %{conn: conn} do
|
||||
conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([])
|
||||
assert conn.halted
|
||||
|
||||
assert redirected_to(conn) == ~p"/users/log-in"
|
||||
|
||||
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
|
||||
"You must log in to access this page."
|
||||
end
|
||||
|
||||
test "stores the path to redirect to on GET", %{conn: conn} do
|
||||
halted_conn =
|
||||
%{conn | path_info: ["foo"], query_string: ""}
|
||||
|> fetch_flash()
|
||||
|> UserAuth.require_authenticated_user([])
|
||||
|
||||
assert halted_conn.halted
|
||||
assert get_session(halted_conn, :user_return_to) == "/foo"
|
||||
|
||||
halted_conn =
|
||||
%{conn | path_info: ["foo"], query_string: "bar=baz"}
|
||||
|> fetch_flash()
|
||||
|> UserAuth.require_authenticated_user([])
|
||||
|
||||
assert halted_conn.halted
|
||||
assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
|
||||
|
||||
halted_conn =
|
||||
%{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
|
||||
|> fetch_flash()
|
||||
|> UserAuth.require_authenticated_user([])
|
||||
|
||||
assert halted_conn.halted
|
||||
refute get_session(halted_conn, :user_return_to)
|
||||
end
|
||||
|
||||
test "does not redirect if user is authenticated", %{conn: conn, user: user} do
|
||||
conn =
|
||||
conn
|
||||
|> assign(:current_scope, Scope.for_user(user))
|
||||
|> UserAuth.require_authenticated_user([])
|
||||
|
||||
refute conn.halted
|
||||
refute conn.status
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue