Stemplin time tracking
Stemplin is a time tracking application written in Ruby on Rails.
Self hosting
See the self hosing guide here: https://github.com/rubynor/stemplin/blob/main/SELF_HOSTING.md
REST API
Stemplin provides a REST API under /api/v1/ with Bearer token authentication.
Authentication
Generate an API token via the Rails console:
user = User.find_by(email: "[email protected]")
token = user.regenerate_api_token!
puts token # save this — it's only shown once
Use the token in requests:
curl -H "Authorization: Bearer <token>" \
-H "X-Organization-Id: <org_id>" \
https://your-host/api/v1/users/me
The X-Organization-Id header is optional — if omitted, the user's default organization is used.
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/users/me |
Current user info |
| GET | /api/v1/users |
List organization users |
| PATCH | /api/v1/api_token |
Regenerate API token (returns new token) |
| GET | /api/v1/organizations |
List organizations |
| GET | /api/v1/organizations/:id |
Show organization |
| GET/POST | /api/v1/clients |
List / create clients |
| GET/PATCH/DELETE | /api/v1/clients/:id |
Show / update / delete client |
| GET/POST | /api/v1/projects |
List / create projects |
| GET/PATCH/DELETE | /api/v1/projects/:id |
Show / update / delete project |
| GET | /api/v1/tasks |
List tasks |
| GET | /api/v1/tasks/:id |
Show task |
| GET/POST | /api/v1/time_regs |
List / create time registrations |
| GET/PATCH/DELETE | /api/v1/time_regs/:id |
Show / update / delete time registration |
| PATCH | /api/v1/time_regs/:id/timer |
Toggle timer |
| GET | /api/v1/reports |
Aggregated time data |
Time registrations support filtering with ?date=, ?start_date=&end_date=, and ?project_id= query params, plus pagination with ?page=&per_page=.
Token security
API tokens are hashed with SHA-256 before storage. The plaintext token is only returned once at creation/regeneration time. Treat it like a password.
Contributing
See the contribution guidelines in:
https://github.com/rubynor/stemplin/blob/main/CONTRIBUTING.md
Setting Up Locally
Installing the project
Install the project's dependencies by running:
cp .env.sample .env
bin/setup
yarn install
Populate the database from fixtures:
rails db:fixtures:load
Redis
Hotwire will not work without Redis. If it is not running, start it with:
redis-server --daemonize yes
Run the project
Finally, you can run your project locally with:
bin/dev
Open your browser and visit http://localhost:3000, your project should be running!
Lint
Run linter with:
bin/rubocop
Or run autocorrection with:
bin/rubocop -a
Authorization
This project uses ActionPolicy for authorization.
Rules
-
ALWAYS use
authorized_scopewhen querying the database, to prevent data leakage. -
Use
authorize!in EVERY SINGLE controller action, and create a policy for EVERY SINGLE controller action.
Action Policy concepts
Policies
Policies are used to limit a current_user's access to controller methods.
Policies are defined like so:
-
userholds the value ofcurrent_user -
recordholds the value of whatever is passed in to theauthorize!method. If nothing is passed,recordwill hold the model class, that is based on the controller name. In this case that class isClient# app/policies/time_reg_policy.rb
class TimeRegPolicy < ApplicationPolicy def index?
Allows all users to access the index action
true
end end
```ruby
# app/policies/client_policy.rb
class ClientPolicy < ApplicationPolicy
def index?
# As the index action fetches an entire collection, `record` is not relevant
# This allows organization_admins to access the action
user.organization_admin?
end
def create?
# Allows organization_admins in the Client's organization access to the action
user.organization_admin? && user.current_organization == record.oragnization
end
end
Scopes
Scopes are used to scope out records that the current_user can access in a collection.
Define a scope like so:
# app/policies/time_reg_policy.rb
class TimeRegPolicy < ApplicationPolicy
scope_for :relation, :own do |relation|
# Scopes out the user's own TimeRegs
relation.joins(:organization, :user).where(organizations: user.current_organization, user: user).distinct
end
end
# app/policies/client_policy.rb
class ClientPolicy < ApplicationPolicy
scope_for :relation do |relation|
# Scopes out Clients accessible for organization_admin
if user.organization_admin?
relation.joins(:organization).where(organizations: user.current_organization).distinct
else
relation.none
end
end
end
Example usage of policies and scopes
# app/controllers/clients_controller.rb
class ClientsController < ApplicationController
def index
# Where the controller fetches an entire collection,
# use the `authorize!` method without passing in a record (implicitly)
authorize!
@clients = authorized_scope(Client, type: :relation).all
end
def show
@client = authorized_scope(Client, type: :relation).find(params[:id])
# Where the controller fetches a single record,
# use the `authorize!` method passing in a record (explicitly)
authorize! @client
end
def create
# Use `authorized_scope` when initializing a record
@client = authorized_scope(Client, type: :relation).new(client_params)
# Use `authorize!` before saving a record
authorize! @clinet
@client.save!
end
end